diff --git a/c++/小猫钓鱼.md b/大学笔记/C++/小猫钓鱼.md similarity index 100% rename from c++/小猫钓鱼.md rename to 大学笔记/C++/小猫钓鱼.md diff --git a/c++/炸弹人游戏题目.md b/大学笔记/C++/炸弹人游戏题目.md similarity index 100% rename from c++/炸弹人游戏题目.md rename to 大学笔记/C++/炸弹人游戏题目.md diff --git a/JAVA/JAVA EE/Web程序设计笔记01——第一章:Spring的基本应用.md b/大学笔记/Java/JAVA EE/Web程序设计笔记01——第一章:Spring的基本应用.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记01——第一章:Spring的基本应用.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记01——第一章:Spring的基本应用.md diff --git a/JAVA/JAVA EE/Web程序设计笔记02——第一章:依赖注入.md b/大学笔记/Java/JAVA EE/Web程序设计笔记02——第一章:依赖注入.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记02——第一章:依赖注入.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记02——第一章:依赖注入.md diff --git a/JAVA/JAVA EE/Web程序设计笔记03——第二章:Spring中的Bean.md b/大学笔记/Java/JAVA EE/Web程序设计笔记03——第二章:Spring中的Bean.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记03——第二章:Spring中的Bean.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记03——第二章:Spring中的Bean.md diff --git a/JAVA/JAVA EE/Web程序设计笔记04——第二章:Spring中的Bean.md b/大学笔记/Java/JAVA EE/Web程序设计笔记04——第二章:Spring中的Bean.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记04——第二章:Spring中的Bean.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记04——第二章:Spring中的Bean.md diff --git a/JAVA/JAVA EE/Web程序设计笔记05——第三章:Spring AOP.md b/大学笔记/Java/JAVA EE/Web程序设计笔记05——第三章:Spring AOP.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记05——第三章:Spring AOP.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记05——第三章:Spring AOP.md diff --git a/JAVA/JAVA EE/Web程序设计笔记06——第三章:从Spring的角度去实现代理.md b/大学笔记/Java/JAVA EE/Web程序设计笔记06——第三章:从Spring的角度去实现代理.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记06——第三章:从Spring的角度去实现代理.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记06——第三章:从Spring的角度去实现代理.md diff --git a/JAVA/JAVA EE/Web程序设计笔记07——第三章:基于AspectJ实现AOP.md b/大学笔记/Java/JAVA EE/Web程序设计笔记07——第三章:基于AspectJ实现AOP.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记07——第三章:基于AspectJ实现AOP.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记07——第三章:基于AspectJ实现AOP.md diff --git a/JAVA/JAVA EE/Web程序设计笔记08——第四章:Spring的数据库开发.md b/大学笔记/Java/JAVA EE/Web程序设计笔记08——第四章:Spring的数据库开发.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记08——第四章:Spring的数据库开发.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记08——第四章:Spring的数据库开发.md diff --git a/JAVA/JAVA EE/Web程序设计笔记09——第五章:Spring的事务管理.md b/大学笔记/Java/JAVA EE/Web程序设计笔记09——第五章:Spring的事务管理.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记09——第五章:Spring的事务管理.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记09——第五章:Spring的事务管理.md diff --git a/JAVA/JAVA EE/Web程序设计笔记10——第六章:初识MyBatis.md b/大学笔记/Java/JAVA EE/Web程序设计笔记10——第六章:初识MyBatis.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记10——第六章:初识MyBatis.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记10——第六章:初识MyBatis.md diff --git a/JAVA/JAVA EE/Web程序设计笔记11——第七章:MyBatis核心配置.md b/大学笔记/Java/JAVA EE/Web程序设计笔记11——第七章:MyBatis核心配置.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记11——第七章:MyBatis核心配置.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记11——第七章:MyBatis核心配置.md diff --git a/JAVA/JAVA EE/Web程序设计笔记12——第八章:动态SQL.md b/大学笔记/Java/JAVA EE/Web程序设计笔记12——第八章:动态SQL.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记12——第八章:动态SQL.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记12——第八章:动态SQL.md diff --git a/JAVA/JAVA EE/Web程序设计笔记13——第九章:MyBatis 的关系映射.md b/大学笔记/Java/JAVA EE/Web程序设计笔记13——第九章:MyBatis 的关系映射.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记13——第九章:MyBatis 的关系映射.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记13——第九章:MyBatis 的关系映射.md diff --git a/JAVA/JAVA EE/Web程序设计笔记14——第十章:Spring 和 MyBatis 的整合.md b/大学笔记/Java/JAVA EE/Web程序设计笔记14——第十章:Spring 和 MyBatis 的整合.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记14——第十章:Spring 和 MyBatis 的整合.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记14——第十章:Spring 和 MyBatis 的整合.md diff --git a/JAVA/JAVA EE/Web程序设计笔记15——第十一章:Spring MVC.md b/大学笔记/Java/JAVA EE/Web程序设计笔记15——第十一章:Spring MVC.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记15——第十一章:Spring MVC.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记15——第十一章:Spring MVC.md diff --git a/JAVA/JAVA EE/Web程序设计笔记16——第十二章:Spring MVC 的核心类和注解.md b/大学笔记/Java/JAVA EE/Web程序设计笔记16——第十二章:Spring MVC 的核心类和注解.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记16——第十二章:Spring MVC 的核心类和注解.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记16——第十二章:Spring MVC 的核心类和注解.md diff --git a/JAVA/JAVA EE/Web程序设计笔记17——第十三章:数据绑定.md b/大学笔记/Java/JAVA EE/Web程序设计笔记17——第十三章:数据绑定.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记17——第十三章:数据绑定.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记17——第十三章:数据绑定.md diff --git a/JAVA/JAVA EE/Web程序设计笔记18——第十四章:JSON 数据和 RESTful 风格的 url.md b/大学笔记/Java/JAVA EE/Web程序设计笔记18——第十四章:JSON 数据和 RESTful 风格的 url.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记18——第十四章:JSON 数据和 RESTful 风格的 url.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记18——第十四章:JSON 数据和 RESTful 风格的 url.md diff --git a/JAVA/JAVA EE/Web程序设计笔记19——第十五章:拦截器.md b/大学笔记/Java/JAVA EE/Web程序设计笔记19——第十五章:拦截器.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记19——第十五章:拦截器.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记19——第十五章:拦截器.md diff --git a/JAVA/JAVA EE/Web程序设计笔记20——第十六章:文件上传和下载.md b/大学笔记/Java/JAVA EE/Web程序设计笔记20——第十六章:文件上传和下载.md similarity index 100% rename from JAVA/JAVA EE/Web程序设计笔记20——第十六章:文件上传和下载.md rename to 大学笔记/Java/JAVA EE/Web程序设计笔记20——第十六章:文件上传和下载.md diff --git a/JAVA/Springboot/1.SpringBoot之项目文件作用.md b/大学笔记/Java/Springboot/1.SpringBoot之项目文件作用.md similarity index 100% rename from JAVA/Springboot/1.SpringBoot之项目文件作用.md rename to 大学笔记/Java/Springboot/1.SpringBoot之项目文件作用.md diff --git a/JAVA/Springboot/2.整合JUnit、Mybatis.md b/大学笔记/Java/Springboot/2.整合JUnit、Mybatis.md similarity index 100% rename from JAVA/Springboot/2.整合JUnit、Mybatis.md rename to 大学笔记/Java/Springboot/2.整合JUnit、Mybatis.md diff --git a/JAVA/Springboot/3.整合MyBatis-plus、Druid.md b/大学笔记/Java/Springboot/3.整合MyBatis-plus、Druid.md similarity index 100% rename from JAVA/Springboot/3.整合MyBatis-plus、Druid.md rename to 大学笔记/Java/Springboot/3.整合MyBatis-plus、Druid.md diff --git a/JAVA/报错以及问题/Mybaits连接MySQL80版本的配置.md b/大学笔记/Java/报错以及问题/Mybaits连接MySQL80版本的配置.md similarity index 100% rename from JAVA/报错以及问题/Mybaits连接MySQL80版本的配置.md rename to 大学笔记/Java/报错以及问题/Mybaits连接MySQL80版本的配置.md diff --git a/JAVA/报错以及问题/Resource注解无法导入依赖.md b/大学笔记/Java/报错以及问题/Resource注解无法导入依赖.md similarity index 100% rename from JAVA/报错以及问题/Resource注解无法导入依赖.md rename to 大学笔记/Java/报错以及问题/Resource注解无法导入依赖.md diff --git a/JAVA/报错以及问题/关于jdbc连接mysql URL上的常见问题.md b/大学笔记/Java/报错以及问题/关于jdbc连接mysql URL上的常见问题.md similarity index 100% rename from JAVA/报错以及问题/关于jdbc连接mysql URL上的常见问题.md rename to 大学笔记/Java/报错以及问题/关于jdbc连接mysql URL上的常见问题.md diff --git a/Linux/Ubuntu 改中文教程.md b/大学笔记/Linux/Ubuntu 改中文教程.md similarity index 100% rename from Linux/Ubuntu 改中文教程.md rename to 大学笔记/Linux/Ubuntu 改中文教程.md diff --git a/Linux/VMware 16PRO虚拟机以及 Ubuntu 系统配置.md b/大学笔记/Linux/VMware 16PRO虚拟机以及 Ubuntu 系统配置.md similarity index 100% rename from Linux/VMware 16PRO虚拟机以及 Ubuntu 系统配置.md rename to 大学笔记/Linux/VMware 16PRO虚拟机以及 Ubuntu 系统配置.md diff --git a/Pychrm/py_基础知识.md b/大学笔记/Python/基础知识.md similarity index 100% rename from Pychrm/py_基础知识.md rename to 大学笔记/Python/基础知识.md diff --git a/安卓/SaveQQ.md b/大学笔记/安卓/SaveQQ.md similarity index 100% rename from 安卓/SaveQQ.md rename to 大学笔记/安卓/SaveQQ.md diff --git a/安卓/内容观察者.md b/大学笔记/安卓/内容观察者.md similarity index 100% rename from 安卓/内容观察者.md rename to 大学笔记/安卓/内容观察者.md diff --git a/安卓/第11章:网络编程.md b/大学笔记/安卓/第11章:网络编程.md similarity index 100% rename from 安卓/第11章:网络编程.md rename to 大学笔记/安卓/第11章:网络编程.md diff --git a/安卓/第七章:检测数据变化.md b/大学笔记/安卓/第七章:检测数据变化.md similarity index 100% rename from 安卓/第七章:检测数据变化.md rename to 大学笔记/安卓/第七章:检测数据变化.md diff --git a/安卓/第三章:Android常见界面控件.md b/大学笔记/安卓/第三章:Android常见界面控件.md similarity index 100% rename from 安卓/第三章:Android常见界面控件.md rename to 大学笔记/安卓/第三章:Android常见界面控件.md diff --git a/安卓/第三章:数据输入输出流.md b/大学笔记/安卓/第三章:数据输入输出流.md similarity index 100% rename from 安卓/第三章:数据输入输出流.md rename to 大学笔记/安卓/第三章:数据输入输出流.md diff --git a/安卓/第九章:服务.md b/大学笔记/安卓/第九章:服务.md similarity index 100% rename from 安卓/第九章:服务.md rename to 大学笔记/安卓/第九章:服务.md diff --git a/安卓/第二章:Android常见界面布局.md b/大学笔记/安卓/第二章:Android常见界面布局.md similarity index 100% rename from 安卓/第二章:Android常见界面布局.md rename to 大学笔记/安卓/第二章:Android常见界面布局.md diff --git a/安卓/第八章:广播机制.md b/大学笔记/安卓/第八章:广播机制.md similarity index 100% rename from 安卓/第八章:广播机制.md rename to 大学笔记/安卓/第八章:广播机制.md diff --git a/安卓/第十章:Android 事件处理.md b/大学笔记/安卓/第十章:Android 事件处理.md similarity index 100% rename from 安卓/第十章:Android 事件处理.md rename to 大学笔记/安卓/第十章:Android 事件处理.md diff --git a/安卓/第四章:程序活动单元Activity.md b/大学笔记/安卓/第四章:程序活动单元Activity.md similarity index 100% rename from 安卓/第四章:程序活动单元Activity.md rename to 大学笔记/安卓/第四章:程序活动单元Activity.md diff --git a/数据库导论/笔记/安装设置.md b/大学笔记/数据库导论/笔记/安装设置.md similarity index 100% rename from 数据库导论/笔记/安装设置.md rename to 大学笔记/数据库导论/笔记/安装设置.md diff --git a/数据库导论/笔记/数据库笔记01——数据库使用笔记.md b/大学笔记/数据库导论/笔记/数据库笔记01——数据库使用笔记.md similarity index 100% rename from 数据库导论/笔记/数据库笔记01——数据库使用笔记.md rename to 大学笔记/数据库导论/笔记/数据库笔记01——数据库使用笔记.md diff --git a/数据库导论/笔记/数据库笔记02——数据库的操作——2022.03.08.md b/大学笔记/数据库导论/笔记/数据库笔记02——数据库的操作——2022.03.08.md similarity index 100% rename from 数据库导论/笔记/数据库笔记02——数据库的操作——2022.03.08.md rename to 大学笔记/数据库导论/笔记/数据库笔记02——数据库的操作——2022.03.08.md diff --git a/数据库导论/笔记/数据库笔记03——数据类型——2022-03-10.md b/大学笔记/数据库导论/笔记/数据库笔记03——数据类型——2022-03-10.md similarity index 100% rename from 数据库导论/笔记/数据库笔记03——数据类型——2022-03-10.md rename to 大学笔记/数据库导论/笔记/数据库笔记03——数据类型——2022-03-10.md diff --git a/数据库导论/笔记/数据库笔记04——插入数据——2022-03-15.md b/大学笔记/数据库导论/笔记/数据库笔记04——插入数据——2022-03-15.md similarity index 100% rename from 数据库导论/笔记/数据库笔记04——插入数据——2022-03-15.md rename to 大学笔记/数据库导论/笔记/数据库笔记04——插入数据——2022-03-15.md diff --git a/数据库导论/笔记/数据库笔记05——查询数据——2022-03-17.md b/大学笔记/数据库导论/笔记/数据库笔记05——查询数据——2022-03-17.md similarity index 100% rename from 数据库导论/笔记/数据库笔记05——查询数据——2022-03-17.md rename to 大学笔记/数据库导论/笔记/数据库笔记05——查询数据——2022-03-17.md diff --git a/数据库导论/笔记/数据库笔记06——视图——2022-04-05.md b/大学笔记/数据库导论/笔记/数据库笔记06——视图——2022-04-05.md similarity index 100% rename from 数据库导论/笔记/数据库笔记06——视图——2022-04-05.md rename to 大学笔记/数据库导论/笔记/数据库笔记06——视图——2022-04-05.md diff --git a/数据库导论/笔记/数据库笔记07——索引——2022.04.07.md b/大学笔记/数据库导论/笔记/数据库笔记07——索引——2022.04.07.md similarity index 100% rename from 数据库导论/笔记/数据库笔记07——索引——2022.04.07.md rename to 大学笔记/数据库导论/笔记/数据库笔记07——索引——2022.04.07.md diff --git a/数据库导论/笔记/数据库笔记08——存储——2022.04.11.md b/大学笔记/数据库导论/笔记/数据库笔记08——存储——2022.04.11.md similarity index 100% rename from 数据库导论/笔记/数据库笔记08——存储——2022.04.11.md rename to 大学笔记/数据库导论/笔记/数据库笔记08——存储——2022.04.11.md diff --git a/数据库导论/试题/模拟试卷1.md b/大学笔记/数据库导论/试题/模拟试卷1.md similarity index 100% rename from 数据库导论/试题/模拟试卷1.md rename to 大学笔记/数据库导论/试题/模拟试卷1.md diff --git a/数据库导论/试题/模拟试卷2.md b/大学笔记/数据库导论/试题/模拟试卷2.md similarity index 100% rename from 数据库导论/试题/模拟试卷2.md rename to 大学笔记/数据库导论/试题/模拟试卷2.md diff --git a/数据库导论/试题/综合案例1——数据表的基本操作.md b/大学笔记/数据库导论/试题/综合案例1——数据表的基本操作.md similarity index 100% rename from 数据库导论/试题/综合案例1——数据表的基本操作.md rename to 大学笔记/数据库导论/试题/综合案例1——数据表的基本操作.md diff --git a/数据库导论/试题/综合案例2-记录的插入、更新和删除.md b/大学笔记/数据库导论/试题/综合案例2-记录的插入、更新和删除.md similarity index 100% rename from 数据库导论/试题/综合案例2-记录的插入、更新和删除.md rename to 大学笔记/数据库导论/试题/综合案例2-记录的插入、更新和删除.md diff --git a/数据库导论/试题/综合案例3——数据表查询操作.md b/大学笔记/数据库导论/试题/综合案例3——数据表查询操作.md similarity index 100% rename from 数据库导论/试题/综合案例3——数据表查询操作.md rename to 大学笔记/数据库导论/试题/综合案例3——数据表查询操作.md diff --git a/数据库导论/试题/综合案例4——视图应用.md b/大学笔记/数据库导论/试题/综合案例4——视图应用.md similarity index 100% rename from 数据库导论/试题/综合案例4——视图应用.md rename to 大学笔记/数据库导论/试题/综合案例4——视图应用.md diff --git a/数据库导论/试题/综合案例5——索引.md b/大学笔记/数据库导论/试题/综合案例5——索引.md similarity index 100% rename from 数据库导论/试题/综合案例5——索引.md rename to 大学笔记/数据库导论/试题/综合案例5——索引.md diff --git a/数据库系统原理/单元复习资料/数据库复习.md b/大学笔记/数据库系统原理/单元复习资料/数据库复习.md similarity index 100% rename from 数据库系统原理/单元复习资料/数据库复习.md rename to 大学笔记/数据库系统原理/单元复习资料/数据库复习.md diff --git a/数据库系统原理/单元复习资料/数据库复习2.md b/大学笔记/数据库系统原理/单元复习资料/数据库复习2.md similarity index 100% rename from 数据库系统原理/单元复习资料/数据库复习2.md rename to 大学笔记/数据库系统原理/单元复习资料/数据库复习2.md diff --git a/数据库系统原理/单元复习资料/数据库复习3.md b/大学笔记/数据库系统原理/单元复习资料/数据库复习3.md similarity index 100% rename from 数据库系统原理/单元复习资料/数据库复习3.md rename to 大学笔记/数据库系统原理/单元复习资料/数据库复习3.md diff --git a/数据库系统原理/单元复习资料/数据库复习4.md b/大学笔记/数据库系统原理/单元复习资料/数据库复习4.md similarity index 100% rename from 数据库系统原理/单元复习资料/数据库复习4.md rename to 大学笔记/数据库系统原理/单元复习资料/数据库复习4.md diff --git a/数据库系统原理/单元复习资料/数据库复习5.md b/大学笔记/数据库系统原理/单元复习资料/数据库复习5.md similarity index 100% rename from 数据库系统原理/单元复习资料/数据库复习5.md rename to 大学笔记/数据库系统原理/单元复习资料/数据库复习5.md diff --git a/数据库系统原理/单元复习资料/数据库第二章.png b/大学笔记/数据库系统原理/单元复习资料/数据库第二章.png similarity index 100% rename from 数据库系统原理/单元复习资料/数据库第二章.png rename to 大学笔记/数据库系统原理/单元复习资料/数据库第二章.png diff --git a/数据库系统原理/练习题/第一次上机.md b/大学笔记/数据库系统原理/练习题/第一次上机.md similarity index 100% rename from 数据库系统原理/练习题/第一次上机.md rename to 大学笔记/数据库系统原理/练习题/第一次上机.md diff --git a/数据库系统原理/练习题/第三次上机.md b/大学笔记/数据库系统原理/练习题/第三次上机.md similarity index 100% rename from 数据库系统原理/练习题/第三次上机.md rename to 大学笔记/数据库系统原理/练习题/第三次上机.md diff --git a/数据库系统原理/练习题/第二次上机.md b/大学笔记/数据库系统原理/练习题/第二次上机.md similarity index 100% rename from 数据库系统原理/练习题/第二次上机.md rename to 大学笔记/数据库系统原理/练习题/第二次上机.md diff --git a/数据库系统原理/练习题/第四次上级.md b/大学笔记/数据库系统原理/练习题/第四次上级.md similarity index 100% rename from 数据库系统原理/练习题/第四次上级.md rename to 大学笔记/数据库系统原理/练习题/第四次上级.md diff --git a/软件测试/软件测试-安全测试.md b/大学笔记/软件测试/软件测试-安全测试.md similarity index 100% rename from 软件测试/软件测试-安全测试.md rename to 大学笔记/软件测试/软件测试-安全测试.md diff --git a/软件测试/软件测试-移动app特性.md b/大学笔记/软件测试/软件测试-移动app特性.md similarity index 100% rename from 软件测试/软件测试-移动app特性.md rename to 大学笔记/软件测试/软件测试-移动app特性.md diff --git a/软件测试/软件测试-自动化测试.md b/大学笔记/软件测试/软件测试-自动化测试.md similarity index 100% rename from 软件测试/软件测试-自动化测试.md rename to 大学笔记/软件测试/软件测试-自动化测试.md diff --git a/软件测试/软件测试-软测基础.md b/大学笔记/软件测试/软件测试-软测基础.md similarity index 100% rename from 软件测试/软件测试-软测基础.md rename to 大学笔记/软件测试/软件测试-软测基础.md diff --git a/软件测试/软件测试-黑白盒测试.md b/大学笔记/软件测试/软件测试-黑白盒测试.md similarity index 100% rename from 软件测试/软件测试-黑白盒测试.md rename to 大学笔记/软件测试/软件测试-黑白盒测试.md diff --git a/Linux/LINUX常用命令总结.md b/快捷键/Linux常用命令总结.md similarity index 100% rename from Linux/LINUX常用命令总结.md rename to 快捷键/Linux常用命令总结.md diff --git a/教程/mac命令.md b/快捷键/Mac快捷键.md similarity index 100% rename from 教程/mac命令.md rename to 快捷键/Mac快捷键.md diff --git a/杂记/typora快捷键.md b/快捷键/typora快捷键.md similarity index 100% rename from 杂记/typora快捷键.md rename to 快捷键/typora快捷键.md diff --git a/Activti 流程管理.md b/扩展知识/Activti 流程管理.md similarity index 100% rename from Activti 流程管理.md rename to 扩展知识/Activti 流程管理.md diff --git a/杂记/Maven 生命周期.md b/扩展知识/Maven 生命周期.md similarity index 100% rename from 杂记/Maven 生命周期.md rename to 扩展知识/Maven 生命周期.md diff --git a/TCP概述.md b/扩展知识/TCP概述.md similarity index 100% rename from TCP概述.md rename to 扩展知识/TCP概述.md diff --git a/六大设计模式.md b/扩展知识/六大设计模式.md similarity index 100% rename from 六大设计模式.md rename to 扩展知识/六大设计模式.md diff --git a/教程/网站面板/aaPanel-宝塔面板国际版安装教程(宝塔海外版).md b/杂记/aaPanel-宝塔面板国际版安装教程(宝塔海外版).md similarity index 100% rename from 教程/网站面板/aaPanel-宝塔面板国际版安装教程(宝塔海外版).md rename to 杂记/aaPanel-宝塔面板国际版安装教程(宝塔海外版).md diff --git a/安装环境/CentOS 国内镜像源 x86-64.md b/环境/CentOS 国内镜像源 x86-64.md similarity index 100% rename from 安装环境/CentOS 国内镜像源 x86-64.md rename to 环境/CentOS 国内镜像源 x86-64.md diff --git a/安装环境/Docker-Compose/Docker compose 安装Gitea.md b/环境/Docker-Compose/Docker compose 安装Gitea.md similarity index 100% rename from 安装环境/Docker-Compose/Docker compose 安装Gitea.md rename to 环境/Docker-Compose/Docker compose 安装Gitea.md diff --git a/安装环境/Docker-Compose/Docker-Compose 安装 Nginx Proxy Manager.md b/环境/Docker-Compose/Docker-Compose 安装 Nginx Proxy Manager.md similarity index 100% rename from 安装环境/Docker-Compose/Docker-Compose 安装 Nginx Proxy Manager.md rename to 环境/Docker-Compose/Docker-Compose 安装 Nginx Proxy Manager.md diff --git a/安装环境/HomeBrew/Git.md b/环境/HomeBrew/Git.md similarity index 100% rename from 安装环境/HomeBrew/Git.md rename to 环境/HomeBrew/Git.md diff --git a/安装环境/HomeBrew/Mac OS 神器 HomeBrew.md b/环境/HomeBrew/Mac OS 神器 HomeBrew.md similarity index 100% rename from 安装环境/HomeBrew/Mac OS 神器 HomeBrew.md rename to 环境/HomeBrew/Mac OS 神器 HomeBrew.md diff --git a/安装环境/HomeBrew/Node.md b/环境/HomeBrew/Node.md similarity index 100% rename from 安装环境/HomeBrew/Node.md rename to 环境/HomeBrew/Node.md diff --git a/安装环境/HomeBrew/smartctl 硬盘读写查看.md b/环境/HomeBrew/smartctl 硬盘读写查看.md similarity index 100% rename from 安装环境/HomeBrew/smartctl 硬盘读写查看.md rename to 环境/HomeBrew/smartctl 硬盘读写查看.md diff --git a/安装环境/基本命令/Docker基本命令.md b/环境/基本命令/Docker基本命令.md similarity index 100% rename from 安装环境/基本命令/Docker基本命令.md rename to 环境/基本命令/Docker基本命令.md diff --git a/安装环境/基本命令/Git基本命令.md b/环境/基本命令/Git基本命令.md similarity index 100% rename from 安装环境/基本命令/Git基本命令.md rename to 环境/基本命令/Git基本命令.md diff --git a/安装环境/基本命令/Node基本命令.md b/环境/基本命令/Node基本命令.md similarity index 100% rename from 安装环境/基本命令/Node基本命令.md rename to 环境/基本命令/Node基本命令.md diff --git a/安装环境/基本命令/Redis基本命令.md b/环境/基本命令/Redis基本命令.md similarity index 100% rename from 安装环境/基本命令/Redis基本命令.md rename to 环境/基本命令/Redis基本命令.md diff --git a/安装环境/安装教程/Centos 安装docker.md b/环境/安装教程/Centos 安装docker.md similarity index 100% rename from 安装环境/安装教程/Centos 安装docker.md rename to 环境/安装教程/Centos 安装docker.md diff --git a/安装环境/安装教程/Docker 2375端口开启外网访问.md b/环境/安装教程/Docker 2375端口开启外网访问.md similarity index 100% rename from 安装环境/安装教程/Docker 2375端口开启外网访问.md rename to 环境/安装教程/Docker 2375端口开启外网访问.md diff --git a/安装环境/安装教程/Docker开放2375端口.md b/环境/安装教程/Docker开放2375端口.md similarity index 100% rename from 安装环境/安装教程/Docker开放2375端口.md rename to 环境/安装教程/Docker开放2375端口.md diff --git a/安装环境/安装教程/ELK Stack .md b/环境/安装教程/ELK Stack .md similarity index 100% rename from 安装环境/安装教程/ELK Stack .md rename to 环境/安装教程/ELK Stack .md diff --git a/安装环境/安装教程/ELK.md b/环境/安装教程/ELK.md similarity index 100% rename from 安装环境/安装教程/ELK.md rename to 环境/安装教程/ELK.md diff --git a/安装环境/安装教程/GrayLog 分布式日志.md b/环境/安装教程/GrayLog 分布式日志.md similarity index 100% rename from 安装环境/安装教程/GrayLog 分布式日志.md rename to 环境/安装教程/GrayLog 分布式日志.md diff --git a/安装环境/安装教程/Harbor.md b/环境/安装教程/Harbor.md similarity index 100% rename from 安装环境/安装教程/Harbor.md rename to 环境/安装教程/Harbor.md diff --git a/安装环境/安装教程/Minio.md b/环境/安装教程/Minio.md similarity index 100% rename from 安装环境/安装教程/Minio.md rename to 环境/安装教程/Minio.md diff --git a/安装环境/安装教程/MongoDB.md b/环境/安装教程/MongoDB.md similarity index 100% rename from 安装环境/安装教程/MongoDB.md rename to 环境/安装教程/MongoDB.md diff --git a/安装环境/安装教程/Mysql.md b/环境/安装教程/Mysql.md similarity index 100% rename from 安装环境/安装教程/Mysql.md rename to 环境/安装教程/Mysql.md diff --git a/安装环境/安装教程/Nexus3.md b/环境/安装教程/Nexus3.md similarity index 100% rename from 安装环境/安装教程/Nexus3.md rename to 环境/安装教程/Nexus3.md diff --git a/安装环境/安装教程/Nginx.md b/环境/安装教程/Nginx.md similarity index 100% rename from 安装环境/安装教程/Nginx.md rename to 环境/安装教程/Nginx.md diff --git a/安装环境/安装教程/Portainer.md b/环境/安装教程/Portainer.md similarity index 100% rename from 安装环境/安装教程/Portainer.md rename to 环境/安装教程/Portainer.md diff --git a/安装环境/安装教程/RabbitMQ.md b/环境/安装教程/RabbitMQ.md similarity index 100% rename from 安装环境/安装教程/RabbitMQ.md rename to 环境/安装教程/RabbitMQ.md diff --git a/安装环境/安装教程/Redis.md b/环境/安装教程/Redis.md similarity index 100% rename from 安装环境/安装教程/Redis.md rename to 环境/安装教程/Redis.md diff --git a/安装环境/安装教程/Skywalking链路追踪.md b/环境/安装教程/Skywalking链路追踪.md similarity index 100% rename from 安装环境/安装教程/Skywalking链路追踪.md rename to 环境/安装教程/Skywalking链路追踪.md diff --git a/安装环境/安装教程/Tomcat.md b/环境/安装教程/Tomcat.md similarity index 100% rename from 安装环境/安装教程/Tomcat.md rename to 环境/安装教程/Tomcat.md diff --git a/安装环境/安装教程/Ubuntu安装Docker.md b/环境/安装教程/Ubuntu安装Docker.md similarity index 100% rename from 安装环境/安装教程/Ubuntu安装Docker.md rename to 环境/安装教程/Ubuntu安装Docker.md diff --git a/安装环境/安装教程/YuFei.83721.@.md b/环境/安装教程/YuFei.83721.@.md similarity index 100% rename from 安装环境/安装教程/YuFei.83721.@.md rename to 环境/安装教程/YuFei.83721.@.md diff --git a/安装环境/安装教程/elasticsearch + kibana.md b/环境/安装教程/elasticsearch + kibana.md similarity index 100% rename from 安装环境/安装教程/elasticsearch + kibana.md rename to 环境/安装教程/elasticsearch + kibana.md diff --git a/安装环境/安装教程/gitea+Drone.md b/环境/安装教程/gitea+Drone.md similarity index 100% rename from 安装环境/安装教程/gitea+Drone.md rename to 环境/安装教程/gitea+Drone.md diff --git a/安装环境/安装教程/jenkins.md b/环境/安装教程/jenkins.md similarity index 100% rename from 安装环境/安装教程/jenkins.md rename to 环境/安装教程/jenkins.md diff --git a/安装环境/安装教程/nacos.md b/环境/安装教程/nacos.md similarity index 100% rename from 安装环境/安装教程/nacos.md rename to 环境/安装教程/nacos.md diff --git a/安装环境/安装教程/安装Minio服务.md b/环境/安装教程/安装Minio服务.md similarity index 100% rename from 安装环境/安装教程/安装Minio服务.md rename to 环境/安装教程/安装Minio服务.md diff --git a/青空笔记/C语言程序设计笔记/C语言(一).md b/青空笔记/C语言程序设计笔记/C语言(一).md deleted file mode 100644 index 57c205d..0000000 --- a/青空笔记/C语言程序设计笔记/C语言(一).md +++ /dev/null @@ -1,255 +0,0 @@ -![image-20220531094944782](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rcjsrnpyj21g40i642g.jpg) - -**温馨提示:**所有的笔记(需要使用Typora软件打开)在视频下方简介中直接获取,纯个人录制,不用加什么公众号这些,各位小伙伴直接网盘自取吧。 - -# 计算机思维导论 - -计算机自1946年问世以来,几乎改变了整个世界。 - -现在我们可以通过电脑来做很多事情,比如我们常常听到的什么人工智能、电子竞技、大数据等等,都和计算机息息相关,包括我们现在的手机、平板等智能设备,也是计算机转变而来的。各位可以看看最顶上的这张图片,如果你在小时候接触过计算机,那么一定对这张图片(照片拍摄于1996年,在美国加利福尼亚州加利福尼亚州的锁诺玛县)印象深刻,这张壁纸作为WindowsXP系统的默认壁纸,曾经展示在千家万户的电脑屏幕上。 - -也许你没有接触过计算机,也许你唯一接触计算机就是用来打游戏,也有可能你曾经捣鼓过计算机,在学习C语言之前,先让我们来了解一下计算机的世界。 - -## 计算机的世界 - -计算机虽然名字听着很高级,不过它也是由一个个简单电路组成的。 - -![image-20220531100709841](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rd1x1rtgj21dy0cw74r.jpg) - -这是我们在初中就学习过的电路图,不过这种电路太过简单,只能完成一些很基础的的操作,比如点亮小灯泡等。 - -很明显想要实现计算机怎么高级的运算机器,肯定是做不到的,这时我们就需要引入更加强大的数字电路了。 - -> 用数字信号完成对数字量进行[算术运算](https://baike.baidu.com/item/算术运算/3118202)和[逻辑运算](https://baike.baidu.com/item/逻辑运算/7224729)的电路称为数字电路,或数字系统。由于它具有逻辑运算和逻辑处理功能,所以又称数字逻辑电路。现代的数字电路由半导体工艺制成的若干数字集成器件构造而成。逻辑门是数字逻辑电路的[基本单元](https://baike.baidu.com/item/基本单元/5246264)。 -> -> 计算机专业一般会在大一开放《数字电路》这门课程,会对计算机底层的数字电路实现原理进行详细介绍。 - -数字电路引入了逻辑判断,我们来看看简单的数字电路: - -![image-20220531102337270](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rdj1916aj21iq0dygm4.jpg) - -数字电路中,用电压的高低来区分出两种信号,低电压表示0,高电压表示1,由于只能通过这种方式表示出两种类型的信号,所以计算机采用的是二进制。 - -> [二进制](https://baike.baidu.com/item/二进制/361457)是计算技术中广泛采用的一种[数制](https://baike.baidu.com/item/数制/217113)。二进制数据是用0和1两个数码来表示的数。它的[基数](https://baike.baidu.com/item/基数/4260)为2,进位规则是“逢二进一”,借位规则是“借一当二”。 -> -> 比如我们一般采用的都是十进制表示,比如9再继续加1的话,就需要进位了,变成10,在二进制中,因为只有0和1,所以当1继续加1时,就需要进位了,就变成10了(注意这不是十,读成一零就行了) - -当然,仅仅有两种信号还不够,我们还需要逻辑门来辅助我们完成更多的计算,最基本的逻辑关系是与、或、非,而逻辑门就有相应的是[与门](https://baike.baidu.com/item/与门)、[或门](https://baike.baidu.com/item/或门)和[非门](https://baike.baidu.com/item/非门),可以用电阻、电容、二极管、三极管等分立原件构成(具体咋构成的咱这里就不说了) - -比如与操作,因为只有两种类型,我们一般将1表示为真,0表示为假,与操作(用&表示)要求两个数参与进来,比如: - -* 1 & 1 = 1 必须两边都是真,结果才为真。 -* 1 & 0 = 0 两边任意一个或者都不是真,结果为假。 - -或运算(用 | 表示): - -* 1 | 0 = 1 两边只要有一个为真,结果就为真 -* 0 | 0 = 0 两边同时为假,结果才是假 - -非运算实际上就是取反操作(可以是 ! 表示) - -* !1 = 0 -* !0 = 1 非运算会将真变成假,假变成真 - -有了这些运算之后,我们的电路不仅仅可以实现计算,也可以实现各种各样的逻辑判断,最终才能发展成我们的计算机。 - -前面我们大概介绍一下计算机的底层操作原理,接着我们来看看计算机的基本组成。 - -![image-20220531105143154](https://tva1.sinaimg.cn/large/e6c9d24ely1h2reca0cgoj20x60b40uu.jpg) - -相信各位熟知的计算机都是一个屏幕+一个主机的形式,然后配上我们的键盘鼠标,就可以开始使用了,但是实际上标准的计算机结构并没有这么简单,我们来看看: - -![image-20220531105407595](https://tva1.sinaimg.cn/large/e6c9d24ely1h2reersjdmj21k20fqdhl.jpg) - -我们电脑最核心的部件,当属CPU,因为几乎所有的运算都是依靠CPU进行(各种各样的计算电路已经在CPU中安排好了,我们只需要发送对应的指令就可以进行对应的运算),它就像我们人的大脑一样,有了大脑才能进行思考。不过光有大脑还不行,还要有一些其他的部分来辅助工作,比如我们想向电脑里面打字,那么就需要连接一个键盘才能输入,我们想要点击桌面上的图标,那么就需要一个鼠标来操作光标,这些都是输入设备。我们的电脑开机之后显示器上会显示出画面,实际上显示器就是输出设备。 - -当然除了这些内容之外,我们的电脑还需要内存来保存运行时的一些数据,以及外存来保存文件(比如硬盘)等。我们常说的iPhone13 512G,这个512G并不是指的内存,而是指的外存,准确的说是用于存放文件硬盘大小,而真正的内存是我们常说的4G/6G/8G运行内存,内存的速度远高于外存的速度,所以1G内存的价格远超1G硬盘的价格。 - -![image-20220531132920027](https://tva1.sinaimg.cn/large/e6c9d24ely1h2riw9u06uj21bs0b4jso.jpg) - -计算机包括五大部件:运算器、控制器、存储器、输入和输出设备。有了这一套完整的硬件环境,我们的电脑才算是有了一个完整的身体。 - -**问题:**我们上面提到的这些硬件设备哪些是属于外设? - -## 操作系统概述 - -前面我们了解了一下计算机的大致原理和组成结构,但是光有这一套硬件可不行,如何让这一套硬件按照我们想要的方式运作起来,也是非常重要的,这时我们就需要介绍操作系统了。 - -> 操作系统(operating system,简称OS)是管理[计算机硬件](https://baike.baidu.com/item/计算机硬件/5459592)与[软件](https://baike.baidu.com/item/软件/12053)资源的[计算机程序](https://baike.baidu.com/item/计算机程序/3220205)。操作系统需要处理如管理与[配置](https://baike.baidu.com/item/配置/2394679)[内存](https://baike.baidu.com/item/内存/103614)、决定[系统资源](https://baike.baidu.com/item/系统资源/974435)供需的优先次序、控制[输入设备](https://baike.baidu.com/item/输入设备/10823368)与[输出设备](https://baike.baidu.com/item/输出设备/10823333)、操作网络与管理[文件系统](https://baike.baidu.com/item/文件系统/4827215)等基本事务。操作系统也提供一个让用户与系统[交互](https://baike.baidu.com/item/交互/6964417)操作的界面。 -> -> 一般在计算机专业大二,会开放《操作系统》课程,会详细讲解操作系统的底层运作机制和调度。 - -一般我们电脑上都安装了Windows操作系统(苹果笔记本安装的是MacOS操作系统),现在主流的电脑都已经预装Windows11了: - -![image-20220531135531352](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rjnis7lkj21ew0e041a.jpg) - -有了操作系统,我们的电脑才能真正运行起来,我们就可以轻松地通过键盘和鼠标来操作电脑了。 - -不过操作系统最开始并不是图形化界面,它类似于Windows中的命令提示符: - -![image-20220531142155616](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rkezow9dj21v60ew40f.jpg) - -![image-20220531142617124](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rkjitqqnj21kc08475e.jpg) - -没有什么图标这些概念,只有一个简简单单的黑框让我们进行操作,通过输入命令来进行一些简单的使用,程序的运行结果也会在黑框框(命令行)中打印出来,不过虽然仅仅是一个黑框,但是能运行的程序可是非常非常多的,只需要运行我们编写好的程序,就能完成各种各样复杂的计算任务,并且计算机的计算速度远超我们的人脑。 - -> 中国超级计算机系统天河二号,计算速度达到每秒5.49亿亿次。 - -当然,除了我们常见的Windows和MacOS系统之外,还有我们以后需要经常打交道的Linux操作系统,这种操作系统是开源的,意思是所有的人都可以拿到源代码进行修改,于是就出现了很多发行版: - -![image-20220531142844465](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rkm3bo7sj21gk0b4dh3.jpg) - -这些发行版有带图形化界面的,也有不带图形化界面的,不带图形化界面的Linux将是我们以后学习的重点。 - -不同操作系统之间的软件并不是通用的,比如Windows下我们的软件一般是.exe后缀名称,而MacOS下则不是,并且也无法直接运行.exe文件,这是因为不同操作系统的具体实现会存在一些不同,程序编译(我们之后会介绍到)之后的格式也会不同,所以是无法做到软件通用的。 - -正是因为有了操作系统,才能够组织我们计算机的底层硬件(包括CPU、内存、输入输出设备等)进行有序工作,没有操作系统电脑就如同一堆废铁,只有躯壳没有灵魂。 - -## 计算机编程语言 - -现在我们大致了解了我们的电脑的运作原理,实际上是一套完整的硬件+一个成形的操作系统共同存在的。接着我们就可以开始了解一下计算机的编程语言了。我们前面介绍的操作系统也是由编程语言写出来的,操作系统本身也算是一个软件。 - -![image-20220531144405958](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rl228014j21eq0a2gnf.jpg) - -那么操作系统是如何让底层硬件进行工作的呢?实际上就是通过向CPU发送指令来完成的。 - -> 计算机指令就是指挥机器工作的指示和命令,程序就是一系列按一定顺序排列的指令,执行程序的过程就是计算机的工作过程。指令集,就是CPU中用来计算和控制计算机系统的一套指令的集合,而每一种新型的CPU在设计时就规定了一系列与其他硬件电路相配合的指令系统。而指令集的先进与否,也关系到CPU的性能发挥,它也是CPU性能体现的一个重要标志。 - -我们电脑中的CPU有多种多样的,不同的CPU之间可能也会存在不同的架构,比如现在最常用的是x86架构,还有我们手机平板这样的移动设备使用的arm架构,不同的架构指令集也会有不同。 - -我们知道,计算机底层硬件都是采用的0和1这样的二进制表示,所以指令也是一样的,比如(这里随便写的): - -* 000001 - 代表开机 -* 000010 - 代表关机 -* 000011 - 代表进行加法运算 - -当我们通过电路发送给CPU这样的二进制指令,CPU就能够根据我们的指令执行对应的任务,而我们编写的程序保存在硬盘中也是这样的二进制形式,我们只需要将这些指令组织好,按照我们的思路一条一条执行对应的命令,就能够让计算机计算任何我们需要的内容了,这其实就是机器语言。 - -不过随着时代的进步,指令集越来越大,CPU支持的运算类型也越来越多,这样的纯二进制编写实在是太累了,并且越来越多的命令我们根本记不住,于是就有了汇编语言。汇编语言将这些二进制的操作码通过助记符来替换: - -* MOV 传送字或字节。 -* MOVSX 先符号扩展,再传送。 -* MOVZX 先零扩展,再传送。 -* PUSH 把字压入堆栈。 - -把这些原有的二进制命令通过一个单词来代替,这样是不是就好记多了,在程序编写完成后,我们只需要最后将这些单词转换回二进制指令就可以了,这也是早期出现的低级编程语言。 - -![image-20220531150346899](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rlmjqp3dj217e09agmz.jpg) - -不过虽然通过这些助记符就能够很轻松地记住命令,但是还是不够方便,因为可能我们的程序需要完成一个很庞大的任务,但是如果还是这样一条一条指令进行编写,是不是太慢了点,有时候可能做一个简单的计算,都需要好几条指令来完成。于是,高级编程语言——C语言,终于诞生了。 - -> C语言诞生于美国的[贝尔实验室](https://baike.baidu.com/item/贝尔实验室/686816),由[丹尼斯·里奇](https://baike.baidu.com/item/丹尼斯·里奇/7267171)(Dennis MacAlistair Ritchie)以肯尼斯·蓝·汤普森(Kenneth Lane Thompson)设计的[B语言](https://baike.baidu.com/item/B语言/1845842)为基础发展而来,在它的主体设计完成后,汤普森和里奇用它完全重写了UNIX操作系统,且随着UNIX操作系统的发展,C语言也得到了不断的完善。 - -高级语言不同于低级语言,低级语言的主要操作对象是指令本身,而高级语言却更加符合我们人脑的认知,更像是通过我们人的思维,去告诉计算机你需要做什么,包括语法也会更加的简单易懂。下面是一段简单的C语言代码: - -```c -int main() { - int a = 10; //定义一个a等于10 - int b = 10; //定义一个b等于10 - int c = a + b; //语义非常明确,c就是a加上b计算出来的结果。 - return 0; -} -``` - -不过现在看不懂没关系,我们后面慢慢学。 - -C语言虽然支持按照我们更容易理解的方式去进行编程,但是最后还是会编译成汇编指令最后变成计算机可以直接执行的指令,不过具体的编译过程,我们不需要再关心了,我们只需要去写就可以了,而对我们代码进行编译的东西,称为编译器。 - -![image-20220603123928430](https://tva1.sinaimg.cn/large/e6c9d24egy1h2uzn81nwsj21qk07mdhd.jpg) - -当然,除了C语言之外,还有很多其他的高级语言,比如Java、Python、C#、PHP等等,相比其他编程语言,C算是比较古老的一种了,但是时隔多年直至今日,其他编程语言也依然无法撼动它的王者地位: - -![image-20220531151304124](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rlw7fvhmj20xo0b0wf4.jpg) - -可以看到在2021年9月,依然排在编程语言排行榜的**第一名**(Python和Java紧随其后),可见这门语言是多么的不可撼动,很多操作系统、高级编程语言底层实现,几乎都是依靠C语言去编写的(包括Java的底层也是C/C++实现的)所以学习这一门语言,对于理工科尤其是计算机专业极为重要,学好C语言你甚至可以融汇贯通到其他语言,学起来也会轻松很多。 - -那么从下节课开始,我们就先做好一些环境上的准备。 - -## C语言开发环境部署 - -完成开发环境部署之后,我们就可以使用C语言来将一句话输出到控制台了,成功编译运行下面的简单程序: - -```c -#include - -int main() { - printf("Hello, World!\n"); - return 0; -} -``` - -首先,我们既然要将我们编写的C语言代码进行编译,那么肯定得找到一个合适的编译器才行,现代的集成开发环境IDE一般都包含了这些编译器,所以我们不需要进行单独的安装。 - -我们只需要找一个集成开发环境去安装就行了,目前功能比较完善的集成开发环境有: - -* Codeblocks(支持Windows、Linux、MacOS操作系统) -* Visual Studio(支持Windows、MacOS操作系统) -* CLion(支持Windows、Linux、MacOS操作系统) - -这里我们就使用CLion作为我们的开发工具使用(这个IDE是收费的,但是学生可以申请免费使用,别担心,大学四年肯定是够你用了,选这个是考虑到后面同学们可能会继续学习Java,Java语言的推荐IDE也是同一个公司的产品,界面都长得差不多)当然如果你想要使用其他的开发工具,也可以,但是这里我们就不演示了。 - -首先前往官网下载:[CLion: A Cross-Platform IDE for C and C++ by JetBrains](https://www.jetbrains.com/clion/) - -![image-20220531155158399](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rn0oo80lj22h00eudia.jpg) - -下载完成后我们直接点击安装: - -![image-20220531160109168](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rna8jfzdj21wa0lg438.jpg) - -如果你不是很熟悉,建议直接点Next安装到C盘默认路径,不要去修改,当然如果确实C盘没有空间,那可以自行修改为其他路径,但是注意最好路径中不要出现中文。 - -![image-20220531160325932](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rncm5g59j220k0l4q7u.jpg) - -勾选一下创建快捷方式,然后继续点Next等待安装就行了: - -![image-20220531160400589](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rnd7qj7mj22080l67b2.jpg) - -安装完成后,我们可以直接打开: - -![image-20220531161024539](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rnjvf3k1j227w0r079b.jpg) - -这里会提示我们激活,点击按钮去官网注册一个账号。注册完成后,推荐去申请一下学生授权,因为试用只有30天:[Jetbrains学生授权获取指南 - 知乎 (zhihu.com)](https://zhuanlan.zhihu.com/p/378185042) - -![image-20220531161215214](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rnltppqfj22c40segr3.jpg) - -这里我们点击开始试用,然后就可以点击Continue了,现在成功来到主界面: - -![image-20220531161354913](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rnnis1prj22020u00xg.jpg) - -由于是英文,使用不太方便,所以我们安装一下中文插件: - -![image-20220531161457379](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rnom1mcvj223k0u0k1k.jpg) - -现在我们就成功安装好CLion集成开发环境了。 - -![image-20220531161549721](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rnpj7nshj222r0u0wjs.jpg) - -现在我们来创建我们的第一个C语言项目(我们的程序是以一个项目的形式进行管理的,这里知道怎么创建就行了): - -![image-20220531161637209](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rnqck8fyj22450u0tf6.jpg) - -这里选择C可执行文件,然后项目的保存位置可以自行修改,配置完成后点击创建: - -![image-20220531162129788](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rnveombnj21kx0u00vt.jpg) - -可以看到,在创建之后,会自动为我们生成一段示例代码,而之后我们要编写的代码,都在生成的main.c中进行编写,除了这个文件,其他的全部不要去修改,也不用管是什么意思,后面我们会慢慢介绍。 - -接着我们需要配置一下工具链,选择捆绑的MinGW(如果已经有了就不需要配置了) - -![image-20220531163655149](https://tva1.sinaimg.cn/large/e6c9d24ely1h2robghz53j21kq0u0tdo.jpg) - -那么这段示例代码有了,我们如何编译运行呢? - -![image-20220531162447514](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rnyucqxmj21kv0u00wb.jpg) - -我们可以点击代码旁边的绿色三角形符号或是右上角的绿色三角形,就可以直接编译运行我们的代码了。运行的结果是在控制台输出一个“HelloWorld!”,当然我们也可以直接运行编译出来的可执行文件: - -![image-20220531162638883](https://tva1.sinaimg.cn/large/e6c9d24ely1h2ro0rgqnaj21u20e4di5.jpg) - -我们可以看到,这里生成了一个`项目名称.exe`文件,这种就是Windows环境下可以直接运行的应用程序,我们可以打开这个文件夹,直接使用cmd来运行: - -![image-20220531162824734](https://tva1.sinaimg.cn/large/e6c9d24ely1h2ro2lc6rxj218404qt9n.jpg) - -运行出来的效果是一样的,这种程序实际上就是最原始的命令行程序,输入和输出都是在这种黑框框中进行的,而我们的主要学习目标也是这种命令行程序。 - -这样我们就配置好了开发环境,然后就不要去动其他的东西了,一般新手最容易遇到一些奇奇怪怪的问题。 \ No newline at end of file diff --git a/青空笔记/C语言程序设计笔记/C语言(三).md b/青空笔记/C语言程序设计笔记/C语言(三).md deleted file mode 100644 index 22a199c..0000000 --- a/青空笔记/C语言程序设计笔记/C语言(三).md +++ /dev/null @@ -1,2949 +0,0 @@ -![image-20220619215833870](https://s2.loli.net/2022/06/19/XpyacVrNM16d8qb.png) - -# C语言高级特性 - -前面我们了解了C语言的相关基础内容,我们来看看C语言的高级部分。这一章的学习难道会比较大,尤其是指针板块,因为需要理解计算机内存模型,所以说是很多初学者的噩梦。 - -## 函数 - -其实函数我们在一开始就在使用了: - -```c -int main() { //这是定义函数 - ... -} -``` - -我们程序的入口点就是`main`函数,我们只需要将我们的程序代码编写到主函数中就可以运行了,不够这个函数只是由我们来定义,而不是我们自己来调用。当然,除了主函数之外,我们一直在使用的`printf`也是一个函数,不过这个函数是标准库中已经实现好了的,现在是我们在调用这个函数: - -```c -printf("Hello World!"); //直接通过 函数名称(参数...) 的形式调用函数 -``` - -那么,函数的具体定义是什么呢? - -> 函数是完成特定任务的独立程序代码单元。 - -其实简单来说,函数时为了完成某件任务而生的,可能我们要完成某个任务并不是一行代码就可以搞定的,但是现在可能会遇到这种情况: - -```c -#include - -int main() { - int a = 10; - - printf("H"); //比如下面这三行代码就是我们要做的任务 - printf("A"); - a += 10; - - if(a > 20) { - printf("H"); //这里我们还需要执行这个任务 - printf("A"); - a += 10; - } - - switch (a) { - case 30: - printf("H"); //这里又要执行这个任务 - printf("A"); - a += 10; - - } -} -``` - -我们每次要做这个任务时,都要完完整整地将任务的每一行代码都写下来,如果我们的程序中多处都需要执行这个任务,每个地方都完整地写一遍,实在是太臃肿了,有没有一种更好的办法能优化我们的代码呢? - -这时我们就可以考虑使用函数了,我们可以将我们的程序逻辑代码全部编写到函数中,当我们执行函数时,实际上执行的就是函数中的全部内容,也就是按照我们制定的规则执行对应的任务,每次需要做这个任务时,只需要调用函数即可。 - -我们来看看,如何创建和使用函数。 - -### 创建和使用函数 - -首先我们来看看如何创建一个函数,其实创建一个函数是很简单的,格式如下: - -```c -返回值类型 函数名称([函数参数...]); -``` - -其中函数名称也是有要求的,并不是所有的字符都可以用作函数名称,它的命名规则与变量的命名规则基本一致,所以这里就不一一列出了。 - -函数不仅仅需要完成我们的任务,可能某些函数还需要告诉我们结果,比如我们之前认识的`getchar`函数,这个函数实际上返回了一个int值作为结果(也就是我们输入的字符)我们同样可以将函数返回的结果赋值给变量或是参与运算等等。 - -当然如果我们的函数只需要完成任务,不需要告诉我们结果,返回值类型可以写成`void`表示为空。 - -函数参数我们放在下一个小节中讲解,所以这里我们不使用任何参数,所以这里也将参数设定为`void`表示没有参数(当然为了方便,我们也可以直接什么都不写) - -```c -#include - -void test(void); //定义函数原型,因为C语言是从上往下的,所以如果要在下面的主函数中使用这个函数,一定要定义到它的上面。 - -int main() { - -} - -void test(void){ //函数具体定义,添加一个花括号并在其中编写程序代码,就和我们之前在main中编写一样 - printf("我是测试函数!"); -} -``` - -或是直接在上方写上函数的具体定义: - -```c -#include - -void test(void){ //函数具体定义,添加一个花括号并在其中编写程序代码,就和我们之前在main中编写一样 - printf("我是测试函数!"); -} - -int main() { - -} -``` - -那么现在我们将函数定义好之后,该如何去使用呢? - -```c -int main() { - test(); //这里我们只需要使用 函数名称(); 就可以调用函数了 - printf("Hello World!"); //实际上printf也是一个函数,功能是向控制台打印字符串,只不过这个函数是系统提供的,已经提前实现好了,其中的参数我们后续还会进行介绍。 -} -``` - -![image-20220619224057060](https://s2.loli.net/2022/06/19/s1uqceI3g6kFiXa.png) - -这样,我们就可以很好解决上面的代码复用性的问题,我们只需要将会重复使用的逻辑代码定义到函数中,当我们需要执行时,直接调用编写好的函数就可以了,这样是不是简单多了? - -```c -int main() { - int a = 10; - - test(); //多次使用的情况下,函数会让我们的程序简单很多 - - if(a > 20) test(); - - switch (a) { - case 30: - test(); - } -} -``` - -当然函数除了可以实现代码的复用之外,也可以优化我们的程序,让我们的代码写得更有层次感,我们的程序可能会有很多很多的功能,需要写很多的代码,但是谁愿意去看一个几百行上千行的`main`函数呢?我们可以将每个功能都写到一个对应的函数中,这样就可以大大减少`main`函数中的代码量了。 - -```c -int main() { - func1(); //直接把多行代码写到一个函数中,在main中调用对应的函数,这样能够大幅度减少代码量 - func2(); - func3(); -} -``` - -而我们从一开始就在编写main函数实际上是一种比较特殊的函数,C语言规定程序一律从主函数开始执行,所以这也是为什么我们一定要写成`int main()`的形式。 - -### 全局变量和局部变量 - -现在我们已经了解了如何创建和调用函数,在继续学习后续内容之前,我们需要先认识一下全局变量和局部变量这两个概念(啊这,变量就变量,还分这么细啊?) - -我们首先来看看局部变量,实际上我们之前使用的都是局部变量,比如: - -```c -int main() { - int i = 10; //这里定义的变量i实际上是main函数中的局部变量,它的作用域只能是main函数中,也就是说其他地方是无法使用的 -} -``` - -局部变量只会在其作用域中生效: - -![image-20220621104906130](https://s2.loli.net/2022/06/21/1sTwRq95uWce3Az.png) - -可以看到在其他函数中,无法使用main函数中的变量,因为局部变量的作用域是有限的,比如位于某个函数内部的变量,那么它的作用域就是整个函数内部,而在其他位置均无法访问。又比如我们之前学习的for循环,当我们这样定义时: - -![image-20220621110340649](https://s2.loli.net/2022/06/21/NohbirYPSBVLQap.png) - -可以看到,在for循环中定义的变量i,只能在for循环内部使用,而出了这个花括号之后就用不了了,当然由于作用域不同,所以下面这种写法是完全没问题的: - -```c -int main() { - for (int i = 0; i < 10; ++i) { //虽然这里写了两个for都使用了i,但是由于处于两个不同的作用域,所以这两个i半毛钱关系都没有 - - } - - for (int i = 0; i < 20; ++i) { - - } -} -``` - -所以,清楚了局部变量的作用域之后,我们在编写程序的时候就很清楚了: - -![image-20220621110503710](https://s2.loli.net/2022/06/21/jPTobYLNdn6sgcH.png) - -![image-20220621110555759](https://s2.loli.net/2022/06/21/PaACqnRBTNXkESY.png) - -那么如果现在我们想要在任何位置都能使用一个变量,该怎么办呢?这时就要用到全局变量了: - -```c -#include - -void test(); - -int a = 10; //我们可以直接将变量定义放在外面,这样所有的函数都可以访问了 - -int main() { - a += 10; - test(); //现在各位觉得,这两个操作完成后,a会是多少呢? - printf("%d", a); -} - -void test(){ - a += 10; -} -``` - -![image-20220621111319786](https://s2.loli.net/2022/06/21/Sdya9HZ7lDMTcIA.png) - -因为现在所有函数都能使用全局变量,所以这个结果不难得到。 - -### 函数参数和返回 - -我们的函数可以接受参数来完成任务,比如我们现在想要实现**用一个函数计算两个数的和并输出到控制台。** - -这种情况我们就需要将我们需要进行加法计算的两个数,告诉函数,这样函数才能对这两个数求和,那么怎么才能告诉函数呢?我们可以通过设定参数: - -```c -#include - -void test(int, int); //函数原型中需要写上需要的参数类型,多个参数用逗号隔开,比如这里我们需要的就是两个int类型的参数 - -int main() { - -} - -void test(int a, int b){ //函数具体定义中也要写上,这里的a和b我们称为形式参数(形参),等价于函数中的局部变量,作用域仅限此函数 - printf("%d", a + b); -} -``` - -那么现在定义完成了,该如何使用这个函数呢,还记得我们怎么使用`printf`函数的吗?我们只需要把它所需要的参数填入即可: - -```c -int main() { - test(10, 20); //这里直接填写一个常量、变量或是运算表达式都是可以的,我们称实际传入的值为实际参数(实参) -} -``` - -可以看到,成功计算出结果: - -![image-20220621113243405](https://s2.loli.net/2022/06/21/dauFW2DNL3PnvYG.png) - -实际上我们传入的实参在进入到函数时,会自动给函数中形参(局部变量)进行赋值,这样我们在函数中就可以得到外部传入的参数值了。 - -![image-20220623224355944](https://s2.loli.net/2022/06/23/8zv1O5ZYAQTJNoV.png) - -我们来看看`printf`函数是怎么写的: - -```c -int printf(const char * __restrict, ...) __printflike(1, 2); //看起来比较高级 -``` - -这里我们主要关心它的两个参数,一个是`char *`由于还没有学习指针,这里就把它当做`const char[]`就行了,表示一个不可修改的字符串,而第二个参数我们看到是`...`,这三个点是个啥? - -我们知道,如果我们想要填写具体需要打印的值时,可以一直往后写: - -```c -printf("%d, %d", 1, 2); //参数可以一直写 -``` - -正常情况下我们函数的参数列表都是固定的,怎么才能像这样写很多个呢?这就要用到可变长参数了,不过可变长参数的使用比较麻烦,这里我们就不做讲解了。 - -这里给大家提一个问题,如果我们修改形式参数的值,外面的实参值会跟着发生修改吗? - -```c -#include - -void swap(int, int); - -int main() { - int a = 10, b = 20; - swap(a, b); - - printf("a = %d, b = %d", a, b); //最后会得到什么结果? -} - -void swap(int a, int b){ - int tmp = a; //这里对a和b的值进行交换 - a = b; - b = tmp; -} -``` - -![image-20220623224752943](https://s2.loli.net/2022/06/23/5QbExfHNM76pBOY.png) - -通过结果发现,虽然调用了函数对a和b的值进行交换,但貌似并没有什么卵用。这是为什么呢? - -还记得我们前面说的吗,函数的形参实际上就是函数内的局部变量,它的作用域仅仅是这个函数,而我们外面传入的实参,仅仅知识将值赋值给了函数内的形参而已,并且外部的变量跟函数内部的变量作用域都不同,所以半毛钱关系都没有,这里交换的仅仅是函数内部的两个形参变量值,跟外部作实参的变量没有任何关系。 - -那么,怎么样才能实现通过函数交换两个变量的值呢?这个问题我们会在指针部分进行讨论。 - -不过数组却不受限制,我们在函数中修改数组的值,是直接可以生效的: - -```c -#include - -void test(int arr[]); - -int main() { - int arr[] = {4, 3, 8, 2, 1, 7, 5, 6, 9, 0}; - test(arr); - printf("%d", arr[0]); //打印的是修改后的值了 -} - -void test(int arr[]) { - arr[0] = 999; //数组就可以做到这边修改,外面生效 -} -``` - -我们再来看一个例子: - -```c -#include - -void test(int a){ - a += 10; - printf("%d\n", a); -} - -int main() { - int a = 10; - test(a); - test(a); //连续两次调用,那么这两次的结果会是什么? -} -``` - -可以看到结果都是20,(如果猜对了可以直接跳过,如果你猜测的是20和30的话,需要听我解释了)注意每次调用函数都是单独进行的,并不是复用函数中的形参,不要认为第一次调用函数test就将函数的局部变量变成20了,再次调用就是20+10变成30。实际上这两次调用都是单独进行的,形参a都是在一开始的时候被赋值为实参的值的,这两次调用没有任何关系,并且函数执行完毕后就自动销毁了。 - -那要是我就希望每次调用函数时保留变量的值呢?我们可以使用静态变量: - -```c -#include - -void test(); - -int main() { - test(); - test(); -} - -void test() { - static int a = 20; //静态变量并不会在函数结束时销毁其值,而是保持 - a += 20; - printf("%d ", a); -} -``` - -我们接着来看函数的返回值,并不是所有的函数都是执行完毕就结束了的,可能某些时候我们需要函数告诉我们执行的结果如何,这时我们就需要用到返回值了,比如现在我们希望实现一个函数计算a+b的值: - -```c -#include - -int sum(int ,int ); //现在我们要返回a和b的和(那么肯定也是int类型)所以这里需要将返回值类型修改为int - -int main() { - int a = 10, b = 20; //计算a和b的和 - int result = sum(a, b); //函数执行后,会返回一个int类型的结果,我们可以接收它,也可以像下面一样直接打印,当然也可以参与运算等等。 - printf("a+b=%d", sum(a, b)); -} - -int sum(int a, int b) { - return a + b; //通过return关键字来返回计算的结果 -} -``` - -我们接着来看下一个例子,现在我们希望你通过函数找到数组中第一个小于0的数字并将其返回,如果没有找到任何小于0的数,就返回0即可: - -```c -#include - -int findMin(int arr[], int len); //需要两个参数,一个是数组本身,还有一个是数组的长度 - -int main() { - int arr[] = {1, 4, -9, 2, -4, 7}; - int min = findMin(arr, 6); - printf("第一个小于0的数是:%d", min); -} - -int findMin(int arr[], int len) { - for (int i = 0; i < len; ++i) { - if(arr[i] < 0) return arr[i]; //当判断找到后,直接return返回即可,这样的话函数会直接返回结果,无论后面还有没有代码没有执行完,整个函数都会直接结束。 - } - return 0; //如果没有找到就返回0 -} -``` - -![image-20220623231617525](https://s2.loli.net/2022/06/23/fRw8nbV15dQGIUH.png) - -这里我们使用了`return`关键字来返回结果,注意当我们的程序走到`return`时,无论还有什么内容没执行完,整个函数都将结束,并返回结果。注意带返回值(非void)的函数中的所有情况都需要有一个对应的返回值: - -```c -int test(int a) { - if (a > 0) { - return 10; //当a大于0时有返回语句 - } else{ - //但是当a不大于0时就没有返回值了,这样虽然可以编译通过,但是会有警告(黄标),运行后可能会出现一些无法预知的问题 - } -} -``` - -如果是没有返回值的函数,我们也可以调用`return`来返回,不过默认情况下是可以省略的: - -```c -void test(int a){ - if(a == 10) return; //因为是void,所以什么都不需要加,直接return - printf("%d", a); -} -``` - -### 递归调用 - -我们的函数除了在其他地方被调用之外,也可以自己调用自己(好家伙,套娃是吧),这种玩法我们称为递归。 - -```c -#include - -void test(){ - printf("Hello World!\n"); - test(); //函数自己在调用自己,这样的话下一轮又会进入到这个函数中 -} - -int main() { - test(); -} -``` - -我们可以尝试运行一下上面的程序,会发现程序直接无限打印`Hello World!`这个字符串,这是因为函数自己在调用自己,不断地重复进入到这个函数,理论情况下,它将永远都不会结束,而是无限地执行这个函数的内容。 - -![image-20220623233305190](https://s2.loli.net/2022/06/23/feq6xUPDjSLAKYF.png) - -但是到最后我们的程序还是终止了,这是因为函数调用有最大的深度限制,因为计算机不可能放任函数无限地进行下去。 - -> **(选学)**我们来大致了解一下函数的调用过程,实际上在程序运行时会有一个叫做**函数调用栈**的东西,它用于控制函数的调用: -> -> ```c -> #include //我们以下面的调用关系为例 -> -> void test2(){ -> printf("giao"); -> } -> -> void test(){ -> test2(); //main -> test -> test2 -> printf("giao"); -> } -> -> int main() { -> test(); -> printf("giao"); -> } -> ``` -> -> 其实我们可以很轻易地看出整个调用关系,首先是从main函数进入,然后调用test函数,在test函数中又调用了test2函数,此时我们就需要等待test2函数执行完毕,test才能继续,而main则需要等待test执行完毕才能继续。而实际上这个过程是由函数调用栈在控制的: -> -> ![image-20220623235007335](https://s2.loli.net/2022/06/23/lAfGyoDWBstz6bm.png) -> -> 而当test2函数执行完毕后,每个栈帧又依次从栈中出去: -> -> ![image-20220623235649397](https://s2.loli.net/2022/06/23/IWYsq8m2htNeEaP.png) -> -> 当所有的栈全部出去之后,程序结束。 -> -> 所以这也就不难解释为什么无限递归会导致程序出现错误,因为栈的空间有限,而函数又一直在进行自我调用,所以会导致不断地有新的栈帧进入,最后塞满整个栈的空间,就爆炸了,这种问题我们称为栈溢出(Stack Overflow) - -当然,如果我们好好地按照规范使用递归操作,是非常方便的,比如我们现在需要求某个数的阶乘: - -```c -#include - -int test(int n); - -int main() { - printf("%d", test(3)); -} - -int test(int n){ - if(n == 1) return 1; //因为不能无限制递归下去,所以我们这里添加一个结束条件,在n = 1时返回 - return test(n - 1) * n; //每次都让n乘以其下一级的计算结果,下一级就是n-1了 -} -``` - -通过给递归调用适当地添加结束条件,这样就不会无限循环了,并且我们的程序看起来无比简洁,那么它是如何执行的呢: - -![image-20220624002051266](https://s2.loli.net/2022/06/24/SsJWqGKyQko47Mm.png) - -它看起来就像是一个先走到底部,然后拿到问题的钥匙后逐步返回的一个过程,并在返回的途中不断进行计算最后得到结果(妙啊) - -所以,合理地使用递归反而是一件很有意思的事情。 - -### 实战:斐波那契数列解法其三 - -前面我们介绍了函数的递归调用,我们来看一个具体的实例吧,我们还是以解斐波那契数列为例。 - -既然每个数都是前两个数之和,那么我们是否也可以通过递归的形式不断划分进行计算呢?我们依然可以借鉴之前动态规划的思想,通过划分子问题,分而治之来完成计算。 - -### 实战:汉诺塔 - -什么是汉诺塔? - -> **汉诺塔**(Tower of Hanoi),又称**河内塔**,是一个源于[印度](https://baike.baidu.com/item/印度/121904)古老传说的[益智玩具](https://baike.baidu.com/item/益智玩具/223159)。[大梵天](https://baike.baidu.com/item/大梵天/711550)创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着64片黄金圆盘。大梵天命令[婆罗门](https://baike.baidu.com/item/婆罗门/1796550)把圆盘从下面开始 -> -> **按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。** - -![image-20220624002507501](https://s2.loli.net/2022/06/24/Z7iN526FOQLS3Kz.png) - -这三根柱子我们就依次命名为A、B、C,现在请你设计一个C语言程序,计算N阶(n片圆盘)汉诺塔移动操作的每一步。 - -这个问题看似很难,实际上我们也可以对每一步进行推理: - -> 当汉诺塔只有1阶的情况下:直接把A上的圆盘移动到C,搞定。 - -> 当汉诺塔只有2阶的情况下:我们的最终目标还是需要将A柱最下面的圆盘丢到C,不过现在多了圆盘,我们得先把这个圆盘给处理了,所以我们得把这上面的1个圆盘丢到B上去,这样才能把A最下面的圆盘丢给C。然后再把B上面的1个圆盘丢到C上去 - -> 当汉诺塔只有3阶的情况下:我们的最终目标还是需要将A柱最下面的圆盘丢到C,不过现在多了圆盘,我们得先把这个圆盘给处理了,所以我们得把这上面的2个圆盘丢到B上去,这样才能把A最下面的圆盘丢给C。然后再把B上面的2个圆盘丢到C上 - -实际上我们发现,把A移动到C是一定要进行的,而在进行之前需要先把压在上面全部的圆盘全部放到B去。而移动之后也要把B上的圆盘全部移动到C上去。其实所有的情况下最终都会有一个n=1的情况,将A上的最后一个圆盘移动到C,只是多了一个前面的步骤和后面的步骤。 - -不过难点就是,怎么把A上的n-1个圆盘移动到B去呢?其实这时我们可以依靠C作为中间商,来帮助我们移动(比如n = 3,那么先把最上面的移动到C,然后把第二大的移动到B,再从C上把最小的移动到B上,这样就借助了C完成了两个圆盘的转移),而最后又怎么把B上的圆盘全部移到C去呢,这时就可以依靠A作为中间商,方法同理;实际上大问题最后都会变成n = 2时这样的小问题,只不过是要移动目标不同罢了。 - -只要想通了怎么去借助中间商进行移动,就很好写出程序了。 - -递归函数如下设计: - -```c -//a存放圆盘的初始柱子,b作为中间柱子存放使用,c作为目标柱子,n表示要从a移动到c的圆盘数 -void hanoi(char a, char b, char c, int n){ - -} -``` - -现在我们来实现一下吧。 - -```c -void move(char start, char end, int n){ //用于打印移动操作到控制台,start是起始柱子,end是结束柱子,n是哪一个圆盘 - printf("第%d个圆盘:%c --> %c\n", n, start, end); -} - -void hanoi(char a, char b, char c, int n){ //刚进来的时候,B作为中间柱子,C作为目标柱子,要移动A上的n个圆盘到C去 - if(n == 1) { - move(a, c, n); //无论a,b,c如何变换,通过递归,最后都会变成一个n = 1的问题,直接移动就完事了 - } else{ - hanoi(a, c ,b, n - 1); //首要目标是先把上面n-1个圆盘全部放到B去,这里就变换一下,让B作为目标柱子,C作为中间 - move(a, c, n); //现在A上只剩下一个最大的圆盘了,接着把A最下方的一个圆盘移到C去 - hanoi(b, a, c, n - 1); //最后需要把B上的全部搬到C上去,这里就可以以C为目标柱子,A为中间柱子 - } -} -``` - -简化一波: - -```c -void hanoi(char a, char b, char c, int n){ - if(n == 0) return; - hanoi(a, c ,b, n - 1); - printf("第%d个圆盘:%c --> %c\n", n, a, c); - hanoi(b, a, c, n - 1); -} -``` - -看似如此复杂的问题,其实只需要4行就可以解决了。 - -### 实战:快速排序算法(选学) - -有一个数组: - -```c -int arr[] = {4, 3, 8, 2, 1, 7, 5, 6, 9, 0}; -``` - -现在请你设计一个C语言程序,对数组按照从小到大的顺序进行排序。这里我们使用冒泡排序的进阶版本——**快速排序**来完成,它的核心思想是分而治之,每一轮排序都会选出一个基准,一轮排序完成后,所以比基准小的数一定在左边,比基准大的数一定在右边,在分别通过同样的方法对左右两边的数组进行排序,不断划分,最后完成整个数组的排序。它的效率相比冒泡排序的双重for循环有所提升。 - -```c -#include - -void quickSort(int arr[], int left, int right){ //arr是数组,left是起始下标,right是结束下标 - //请实现这一部分 -} - -int main() { - int arr[] = {4, 3, 8, 2, 1, 7, 5, 6, 9, 0}; - quickSort(arr, 0, 9); //10个数字下标就是0-9 - for (int i = 0; i < 10; ++i) { - printf("%d ", arr[i]); - } -} -``` - -不过虽然这种排序算法很快,但是极端情况下(比如遇到了刚好倒序的数组)还是会退化成冒泡排序的。 - -*** - -## 指针 - -指针可以说是整个C语言中最难以理解的部分了,不过其实说简单也简单,你会发现也并没有想象中的那么难,你与它的距离可能只差了那么一些基础知识,这一部分都会及时进行补充的。 - -### 什么是指针 - -还记得我们在上一个部分谈到的通过函数交换两个变量的值吗? - -```c -#include - -void swap(int, int); - -int main() { - int a = 10, b = 20; - swap(a, b); - - printf("a = %d, b = %d", a, b); //最后会得到什么结果? -} - -void swap(int a, int b){ - int tmp = a; //这里对a和b的值进行交换 - a = b; - b = tmp; -} -``` - -实际上这种写法是错误的,因为交换的并非是真正的a和b,而是函数中的局部变量。那么有没有办法能够直接对函数外部的变量进行操作呢?这就需要指针的帮助了。 - -我们知道,程序中使用的变量实际上都在内存中创建的,每个变量都会被保存在内存的某一个位置上(具体在哪个位置是由系统分配的),就像我们最终会在这个世界上的某个角落安家一样,所有的变量在对应的内存位置上都有一个地址(地址是独一无二的),而我们可以通过这个地址寻找到这个变量本体,比如int占据4字节,因此int类型变量的地址就是这4个字节的起始地址,后面32个bit位全部都是用于存放此变量的值的。 - -![image-20220624221635066](https://s2.loli.net/2022/06/24/zi5ZwxK76REpYUI.png) - -这里的`0x`是十六进制的表示形式(10-15用字母A - F表示)如果我们能够知道变量的内存地址,那么无论身在何处,都可以通过地址找到这个变量了。而指针的作用,就是专门用来保存这个内存地址的。 - -我们来看看如何创建一个指针变量用于保存变量的内存地址: - -```c -#include - -int main(){ - int a = 10; - //指针类型需要与变量的类型相同,且后面需要添加一个*符号(注意这里不是乘法运算)表示是对于类型的指针 - int * p = &a; //这里的&并不是进行按位与运算,而是取地址操作,也就是拿到变量a的地址 - printf("a在内存中的地址为:%p", p); //地址使用%p表示 -} -``` - -![image-20220624222718731](https://s2.loli.net/2022/06/24/Pb3cWuOFIMkJLEa.png) - -可以看到,我们通过取地址操作`&`,将变量a的地址保存到了一个地址变量`p`中。 - -拿到指针之后,我们可以很轻松地获取指针所指地址上的值: - -```c -#include - -int main(){ - int a = 666; - int * p = &a; - printf("内存%p上存储的值为:%d", p, *p); //我们可以在指针变量前添加一个*号(间接运算符,也可以叫做解引用运算符)来获取对应地址存储的值 -} -``` - -注意这里访问指针所指向地址的值时,是根据类型来获取的,比如int类型占据4个字节,那么就读取地址后面4个字节的内容作为一个int值,如果指针是char类型的,那么就只读取地址后面1个字节作为char类型的值。 - -![image-20220624224026228](https://s2.loli.net/2022/06/24/GHS8UAoKNT6vZXy.png) - -同样的,我们也可以直接像这样去修改对应地址存放的值: - -```c -#include - -int main(){ - int a = 666; - int * p = &a; - - *p = 999; //通过*来访问对应地址的值,并通过赋值运算对其进行修改 - - printf("a的值为:%d", a); -} -``` - -![image-20220624225026394](https://s2.loli.net/2022/06/24/3gFKBEuRQlD8wpq.png) - -实际上拿到一个变量的地址之后,我们完全不需要再使用这个变量,而是可以通过它的指针来对其进行各种修改。因此,现在我们想要实现对两个变量的值进行交换的函数就很简单了: - -```c -#include - -// 这里是两个指针类型的形参,其值为实参传入的地址, -// 虽然依然是值传递,但是这里传递的可是地址啊, -// 只要知道地址改变量还不是轻轻松松? -void swap(int * a, int * b){ - int tmp = *a; //先暂存一下变量a地址上的值 - *a = *b; //将变量b地址上的值赋值给变量a对应的位置 - *b = tmp; //最后将a的值赋值给b对应位置,OK,这样就成功交换两个变量的值了 -} - -int main(){ - int a = 10, b = 20; - swap(&a, &b); //只需要把a和b的内存地址给过去就行了,这里取一下地址 - printf("a = %d, b = %d", a, b); -} -``` - -![image-20220624225800731](https://s2.loli.net/2022/06/24/8U6pSiKeEFTg2H4.png) - -通过地址操作,我们就轻松实现了使用函数交换两个变量的值了。 - -了解了指针的相关操作之后,我们再来看看`scanf`函数,实际上就很好理解了: - -```c -#include - -int main(){ - int a; - scanf("%d", &a); //这里就是取地址,我们需要告诉scanf函数变量的地址,这样它才能通过指针访问变量的内存地址,对我们变量的值进行修改,这也是为什么scanf里面的变量(除数组外)前面都要进行一个取地址操作 - printf("%d", a); -} -``` - -当然,和变量一样,要是咱们不给指针变量赋初始值的话,就不知道指的哪里了,因为指针变量也是变量,存放的其他变量的地址值也在内存中保存,如果不给初始值,那么存放别人地址的这块内存可能在其他地方使用过,这样就不知道初始值是多少了(那么指向的地址可能是一个很危险的地址,随意使用可能导致会出现严重错误),所以一定要记得给个初始值或是将其设定为NULL,表示空指针,不指向任何内容。 - -```c -#include - -int main(){ - int * a = NULL; -} -``` - -我们接着来看看`const`类型的指针,这种指针比较特殊: - -```c -#include - -int main(){ - int a = 9, b = 10; - const int * p = &a; - *p = 20; //这里直接报错,因为被const标记的指针,所指地址上的值不允许发生修改 - p = &b; //但是指针指向的地址是可以发生改变的 -} -``` - -我们再来看另一种情况: - -```c -#include - -int main(){ - int a = 9, b = 10; - int * const p = &a; //const关键字被放在了类型后面 - *p = 20; //允许修改所指地址上的值 - p = &b; //但是不允许修改指针存储的地址值,其实就是反过来了。 -} -``` - -当然也可以双管齐下: - -```c -#include - -int main(){ - int a = 9, b = 10; - const int * const p = &a; - *p = 20; //两个都直接报错,都不让改了 - p = &b; -} -``` - -### 指针与数组 - -前面我们介绍了指针的基本使用,我们来回顾一个问题,为什么数组可以以原身在函数之间进行传递呢?先说结论,数组表示法实际上是在变相地使用指针,你甚至可以将其理解为数组变量其实就是一个指针变量,它存放的就是数组中第一个元素的起始地址。 - -为什么这么说? - -```c -#include - -int main(){ - char str[] = "Hello World!"; - char * p = str; //???啥情况,为什么能直接把数组作为地址赋值给指针变量p??? - - printf("%c", *p); //还能正常使用,打印出第一个字符??? -} -``` - -![image-20220624231833371](https://s2.loli.net/2022/06/24/WaPeLR8o295YpsC.png) - -你以为这就完了?还能这样玩呢: - -```c -int main(){ - char str[] = "Hello World!"; - char * p = str; - - printf("%c", p[1]); //???怎么像在使用数组一样用指针??? -} -``` - -![image-20220624232337311](https://s2.loli.net/2022/06/24/hV6orYOmebDRyJG.png) - -太迷了吧,怎么数组和指针还能这样混着用呢?我们先来看看数组在内存中是如何存放的: - -![image-20220624233249216](https://s2.loli.net/2022/06/24/ij6eKTYqDSxL7tE.png) - -数组在内存中是一块连续的空间,所以为什么声明数组一定要明确类型和大小,因为这一块连续的内存空间生成后就固定了。 - -而我们的数组变量实际上存放的就是首元素的地址,而实际上我们之前一直使用的都是**数组表示法**来操作数组,这样可以很方便地让我们对内存中的各个元素值进行操作: - -```c -int main(){ - char str[] = "Hello World!"; - printf("%c", str[0]); //直接在中括号中输入对应的下标就能访问对应位置上的数组了 -} -``` - -而我们知道实际上`str`表示的就是数组的首地址,所以我们完全可以将其赋值给一个指针变量,因为指针变量也是存放的地址: - -```c -char str[] = "Hello World!"; -char * p = str; //直接把str代表的首元素地址给到p -``` - -而使用指针后,实际上我们可以使用另一种表示法来操作数组,这种表示法叫做**指针表示法**: - -```c -int main(){ - char str[] = "Hello World!"; - char * p = str; - - printf("第一个元素值为:%c,第二个元素值为:%c", *p, *(p+1)); //通过指针也可以表示对应位置上的值 -} -``` - -比如我们现在需要表示数组中的第二个元素: - -* 数组表示法:`str[1]` -* 指针表示法:`*(p+1)` - -虽然写法不同,但是他们表示的意义是完全相同的,都代表了数组中的第二个元素,其中指针表示法使用了`p+1`的形式表示第二个元素,这里的`+1`操作并不是让地址+1,而是让地址`+ 一倍的对应类型大小`,也就是说地址后移一个char的长度,所以正好指向了第二个元素,然后通过`*`取到对应的值(注意这种操作仅对数组是有意义的,如果是普通的变量,虽然也可以通过这种方式获得后一个char的长度的数据,但是毫无意义) - -```c -*(p+i) <=> str[i] //实际上就是可以相互转换的 -``` - -这两种表示法都可以对内存中存放的数组内容进行操作,只是写法不同罢了,所以你会看到数组和指针混用也就不奇怪了。了解了这些东西之后,我们来看看下面的各个表达式分别代表什么: - -```c -*p //数组的第一个元素 -p //数组的第一个元素的地址 -p == str //肯定是真,因为都是数组首元素地址 -*str //因为str就是首元素的地址,所以这里对地址加*就代表第一个元素,使用的是指针表示法 -&str[0] //这里得到的实际上还是首元素的地址 -*(p + 1) //代表第二个元素 -p + 1 //第二个元素的内存地址 -*p + 1 //注意*的优先级比+要高,所以这里代表的是首元素的值+1,得到字符'K' -``` - -所以不难理解,为什么`printf`函数的参数是一个`const char * `了,实际上就是需要我们传入一个字符串而已,只不过这里采用的是指针表示法而已。 - -当然指针也可以进行自增和自减操作,比如: - -```c -#include - -int main(){ - char str[] = "Hello World!"; - char * p = str; - - p++; //自增后相当于指针指向了第二个元素的地址 - - printf("%c", *p); //所以这里打印的就是第二个元素的值了 -} -``` - -一维数组看完了,我们来看看二维数组,那么二维数组在内存中是如何表示的呢? - -```c -int arr[2][3] = {{1, 2, 3}, {4, 5, 6}}; -``` - -这是一个2x3的二维数组,其中存放了两个能够容纳三个元素的数组,在内存中,是这样的: - -![image-20220625113701632](https://s2.loli.net/2022/06/25/nEOomiYuMI7UWNy.png) - -所以虽然我们可以使用二维数组的语法来访问这些元素,但其实我们也可以使用指针来进行访问: - -```c -#include - -int main(){ - int arr[][3] = {{1, 2, 3}, {4, 5, 6}}; - int * p = arr[0]; //因为是二维数组,注意这里要指向第一个元素,来降一个维度才能正确给到指针 - //同理如果这里是arr[1]的话那么就表示指向二维数组中第二个数组的首元素 - printf("%d = %d", *(p + 4), arr[1][1]); //实际上这两种访问形式都是一样的 -} -``` - -### 多级指针 - -我们知道,实际上指针本身也是一个变量,它存放的是目标的地址,但是它本身作为一个变量,它也要将地址信息保存到内存中,所以,实际上当我们有指针之后: - -![image-20220625105757445](https://s2.loli.net/2022/06/25/NLyVRJU8OmYBTlM.png) - -实际上,我们我们还可以继续创建一个指向指针变量地址的指针,甚至可以创建更多级(比如指向指针的指针的指针)比如现在我们要创建一个指向指针的指针: - -![image-20220625110252586](https://s2.loli.net/2022/06/25/ISWsVwEDlqLFPbd.png) - -落实到咱们的代码中: - -```c -#include - -int main(){ - int a = 20; - int * p = &a; //指向普通变量的指针 - //因为现在要指向一个int *类型的变量,所以类型为int* 再加一个* - int ** pp = &p; //指向指针的指针(二级指针) - int *** ppp = &pp; //指向指针的指针的指针(三级指针) -} -``` - -那么我们如何访问对应地址上的值呢? - -```c -#include - -int main(){ - int a = 20; - int * p = &a; - int ** pp = &p; - - printf("p = %p, a = %d", *pp, **pp); //使用一次*表示二级指针指向的指针变量,继续使用一次*会继续解析成指针变量所指的普通变量 -} -``` - -本质其实就是一个套娃而已,只要把各个层次分清楚,实际上还是很好理解的。 - -**特别提醒:**一级指针可以操作一维数组,那么二级指针是否可以操作二维数组呢?不能!因为二级指针的含义都不一样了,它是表示指针的指针,而不是表示某个元素的指针了。下面我们会认识数组指针,准确的说它才更贴近于二维数组的形式。 - -### 指针数组与数组指针 - -前面我们了解了指针的一些基本操作,包括它与数组的一些关系。我们接着来看指针数组和数组指针,这两词语看着就容易搞混,不过哪个词在后面就哪个,我们先来看指针数组,虽然名字很像数组指针,但是它本质上是一个数组,不过这个数组是用于存放指针的数组。 - -```c -#include - -int main(){ - int a, b, c; - int * arr[3] = {&a, &b, &c}; //可以看到,实际上本质还是数组,只不过存的都是地址 -} -``` - -因为这个数组中全都是指针,比如现在我们想要访问数组中第一个指针指向的地址: - -```c -#include - -int main(){ - int a, b, c; - int * arr[3] = {&a, &b, &c}; - - *arr[0] = 999; //[]运算符的优先级更高,所以这里先通过[0]取出地址,然后再使用*将值赋值到对应的地址上 - printf("%d", a); -} -``` - -当然我们也可以用二级指针变量来得到指针数组的首元素地址: - -```c -#include - -int main(){ - int * p[3]; //因为数组内全是指针 - int ** pp = p; //所以可以直接使用指向指针的指针来指向数组中的第一个指针元素 -} -``` - -实际上指针数组还是很好理解的,那么数组指针呢?可以看到指针在后,说明本质是一个指针,不过这个指针比较特殊,它是一个指向数组的指针(注意它的目标是整个数组,和我们之前认识的指针不同,之前认识的指针是指向某种类型变量的指针) - -比如: - -```c -int * p; //指向int类型的指针 -``` - -而数组指针则表示指向整个数组: - -```c -int (*p)[3]; //注意这里需要将*p括起来,因为[]的优先级更高 -``` - -注意它的目标是整个数组,而不是普通的指针那样指向的是数组的首个元素: - -```c -int arr[3] = {111, 222, 333}; -int (*p)[3] = &arr; //直接对整个数组再取一次地址(因为数组指针代表的是整个数组的地址,虽然和普通指针都是指向首元素地址,但是意义不同) -``` - -那么现在已经取到了指向整个数组的指针,该怎么去使用呢? - -```c -#include - -int main(){ - int arr[3] = {111, 222, 333}; - int (*p)[3] = &arr; //直接对整个数组再取一次地址 - - printf("%d, %d, %d", *(*p+0), *(*p+1), *(*p+2)); //要获取数组中的每个元素,稍微有点麻烦 -} -``` - -注意此时: - -* `p`代表整个数组的地址 -* `*p`表示所指向数组中首元素的地址 -* `*p+i`表示所指向数组中第`i`个(0开始)元素的地址(实际上这里的*p就是指向首元素的指针) -* `*(*p + i)`就是取对应地址上的值了 - -虽然在处理一维数组上感觉有点麻烦,但是它同样也可以处理二维数组: - -```c -int arr[][3] = {{111, 222, 333}, {444, 555, 666}}; -int (*p)[3] = arr; //二维数组不需要再取地址了,因为现在维度提升,数组指针指向的是二维数组中的其中一个元素(因为元素本身就是一个数组) -``` - -比如现在我们想要访问第一个数组的第二个元素,根据上面p各种情况下的意义: - -```c -printf("%d", *(*p+1)); //因为上面直接指向的就是第一个数组,所以想要获取第一个元素和之前是一模一样的 -``` - -那么要是我们现在想要获取第二个数组中的最后一个元素呢? - -```c -printf("%d", *(*(p+1)+2); //首先*(p+1)为一个整体,表示第二个数组(因为是数组指针,所以这里+1一次性跳一个数组的长度),然后再到外层+2表示数组中的第三个元素,最后再取地址,就是第二个数组的第三个元素了 -``` - -当然也可以使用数组表示法: - -```java -printf("%d", p[1][2]); //好家伙,这不就是二维数组的用法吗,没错,看似很难,你甚至可以认为这两用着是同一个东西 -``` - -### 指针函数与函数指针 - -我们的函数可以返回一个指针类型的结果,这种函数我们就称为**指针函数**。 - -```c -#include - -int * test(int * a){ //函数的返回值类型是int *指针类型的 - return a; -} - -int main(){ - int a = 10; - int * p = test(&a); //使用指针去接受函数的返回值 - printf("%d", *p); - printf("%d", *test(&a)); //当然也可以直接把间接运算符在函数调用前面表示直接对返回的地址取地址上的值 -} -``` - -不过要注意指针函数不要尝试去返回一个局部变量的地址: - -```c -#include - -int * test(int a){ - int i = a; - return &i; //返回局部变量i的地址 -} - -int main(){ - int * p = test(20); //连续调用两次test函数 - test(30); - - printf("%d", *p); //最后结果可能并不是我们想的那样 -} -``` - -![image-20220625133343155](https://s2.loli.net/2022/06/25/my89qFcS73J4Hif.png) - -为什么会这样呢?还记得我们前面说的吗?函数一旦返回,那么其中的局部变量就会全部销毁了,至于这段内存之后又会被怎么去使用,我们也就不得而知了。 - -*局部变量其实是存放在栈帧中的,如果前面的选学部分听了之后,你就知道为什么这里得到的是第二次的30了,因为第二次调用的栈帧入栈后就覆盖了这段内存,又因为是同一个函数所以栈帧结构是一样的,最后在同样的位置就存放了新的30这个值。* - -我们接着来看**函数指针**,实际上指针除了指向一个变量之外,也可以指向一个函数,当然函数指针本身还是一个指针,所以依然是用变量表示,但是它代表的是一个函数的地址(编译时系统会为函数代码分配一段存储空间,这段存储空间的首地址称为这个函数的地址) - -我们来看看如何定义: - -```c -#include - -int sum(int a, int b) { - return a + b; -} - -int main(){ - //类型 (*指针变量名称)(函数参数...) //注意一定要把*和指针变量名称括起来,不然优先级不够 - int (*p)(int, int) = sum; - printf("%p", p); -} -``` - -这样我们就拿到了函数的地址,既然拿到函数的地址,那么我们就可以通过函数的指针调用这个函数了: - -```c -#include - -int sum(int a, int b) { - return a + b; -} - -int main(){ - int (*p)(int, int) = sum; - - int result = (*p)(1, 2); //就像我们正常使用函数那样,(*p)表示这个函数,后面依然是在小括号里面填上实参 - int result = p(1, 2); //当然也可以直接写函数指针变量名称,效果一样(咋感觉就是给函数换了个名呢) - printf("%d", result); -} -``` - -有了函数指针,我们就可以编写函数回调了(所谓回调就让别人去调用我们提供的函数,而不是我们主动来调别人的函数),比如现在我们定义了一个函数,不过这个函数需要参数通过一个处理的逻辑才能正常运行: - -```c -int sum(int (*p)(int, int), int a, int b){ //将函数指针作为参数传入 - //函数回调 - return p(a, b); //就像你进了公司然后花钱请别人帮你写代码,工资咱们五五开,属于是直接让别人帮你实现 -} -``` - -于是我们就还要给他一个其他函数的地址: - -```c -#include - -int sum(int (*p)(int, int), int a, int b){ - return p(a, b); -} - -int sumImpl(int a, int b){ //这个函数实现了a + b - return a + b; -} - -int main(){ - int (*p)(int, int) = sumImpl; //拿到实现那个函数的地址 - printf("%d", sum(p, 10, 20)); -} -``` - -当然,函数指针也可以保存一组函数的地址,成为函数指针数组,但是这里就不多说了,相信各位已经快顶不住了吧。 - -### 实战:合并两个有序数组 - -**来源:力扣 No.88 合并两个有序数组**:https://leetcode.cn/problems/merge-sorted-array/ - -给你两个按 非递减顺序 排列的整数数组 nums1 和 nums2,另有两个整数 m 和 n ,分别表示 nums1 和 nums2 中的元素数目。 - -请你 合并 nums2 到 nums1 中,使合并后的数组同样按 非递减顺序 排列。 - -注意:最终,合并后数组不应由函数返回,而是存储在数组 nums1 中。为了应对这种情况,nums1 的初始长度为 m + n,其中前 m 个元素表示应合并的元素,后 n 个元素为 0 ,应忽略。nums2 的长度为 n 。 - -> 示例 1: -> -> 输入:nums1 = [1,2,3,0,0,0], m = 3, nums2 = [2,5,6], n = 3 -> 输出:[1,2,2,3,5,6] -> 解释:需要合并 [1,2,3] 和 [2,5,6] 。 -> 合并结果是 [1,2,2,3,5,6] ,其中斜体加粗标注的为 nums1 中的元素。 -> -> 示例 2: -> -> 输入:nums1 = [1], m = 1, nums2 = [], n = 0 -> 输出:[1] -> 解释:需要合并 [1] 和 [] 。 -> 合并结果是 [1] 。 -> -> 示例 3: -> -> 输入:nums1 = [0], m = 0, nums2 = [1], n = 1 -> 输出:[1] -> 解释:需要合并的数组是 [] 和 [1] 。 -> 合并结果是 [1] 。 -> 注意,因为 m = 0 ,所以 nums1 中没有元素。nums1 中仅存的 0 仅仅是为了确保合并结果可以顺利存放到 nums1 中。 - -现在请你设计一个C语言程序,实现下面的函数(要求全程使用指针,不允许出现数组用法): - -```c -void merge(int* nums1, int nums1Size, int m, int* nums2, int nums2Size, int n){ - -} -``` - -### 实战:二维数组中的查找 - -**来源:剑指Offer 04. 二维数组中的查找**:https://leetcode.cn/problems/er-wei-shu-zu-zhong-de-cha-zhao-lcof/ - -在一个 n * m 的二维数组中,每一行都按照从左到右递增的顺序排序,每一列都按照从上到下递增的顺序排序。请完成一个高效的函数,输入这样的一个二维数组和一个整数,判断数组中是否含有该整数。 - -> 示例: -> -> 现有矩阵 matrix 如下: -> -> [ -> [1, 4, 7, 11, 15], -> [2, 5, 8, 12, 19], -> [3, 6, 9, 16, 22], -> [10, 13, 14, 17, 24], -> [18, 21, 23, 26, 30] -> ] -> -> 给定 target = 5,返回 true。 -> -> 给定 target = 20,返回 false。 - -现在请你设计一个C语言程序,实现下面的函数(要求全程使用指针,不允许出现数组用法): - -```c -/* - * 输入 **matrix 是长度为 matrixSize 的数组指针的数组,其中每个元素(也是一个数组) - * 的长度组成 *matrixColSize 数组作为另一输入,*matrixColSize 数组的长度也为 matrixSize - */ -bool findNumberIn2DArray(int** matrix, int matrixSize, int* matrixColSize, int target){ - -} -``` - -*** - -## 结构体、联合体和枚举 - -终于熬过了最难的一个部分,后面的内容就相对简单多了,我们接着来看结构体。 - -我们之前认识过很多种数据类型,包括整数、小数、字符、数组等,通过使用对应的数据类型,我们就可以很轻松地将我们的数据进行保存了,但是有些时候,这种简单类型很难去表示一些复杂结构。 - -### 创建和使用结构体 - -比如现在我们要保存100个学生的信息(学生信息包括学号、姓名、年龄)我们发现似乎找不到一种数据类型能够同时保存这三种数据(数组虽然能保存一些列的元素,但是只能保存同种类型的)。但是如果把它们拆开单独存在,就可以使用对应的类型存放了,不过这样也太不方便了吧,这些数据应该是捆绑在一起的,而不是单独地去存放。所以,为了解决这种问题,C语言提供了结构体类型,它能够将多种类型的数据集结到一起,让他们形成一个整体。 - -```c -struct Student { //使用 (struct关键字 + 结构体类型名称) 来声明结构体类型,这种类型是我们自己创建的(同样也可以作为函数的参数、返回值之类的) - int id; //结构体中可以包含多个不同类型的数据,这些数据共同组成了整个结构体类型(当然结构体内部也能包含结构体类型的变量) - int age; - char * name; //用户名可以用指针指向一个字符串,也可以用char数组来存,如果是指针的话,那么数据不会存在结构体中,只会存放字符串的地址,但是如果是数组的话,数据会存放在结构体中 -}; -``` - -```c -int main() { - struct Student { //也可以以局部形式存在 - - }; -} -``` - -定义好结构体后,我们只需要使用结构体名称作为类型就可以创建一个结构体变量了: - -```c -#include - -struct Student { - int id; - int age; - char * name; -}; - -int main() { - //类型需要写为struct Student,后面就是变量名称 - struct Student s = {1, 18, "小明"}; //结构体包含多种类型的数据(它们是一个整体),只需要把这些数据依次写好放在花括号里面就行了 -} -``` - -```c  -struct Student { - int id; - int age; - char * name; -} s; //也可以直接在花括号后面写上变量名称(多个用逗号隔开),声明一个全局变量 -``` - -这样我们就创建好了一个结构体变量,而这个结构体表示的就是学号为1、年龄18、名称为小明的结构体数据了。 - -当然,结构体的初始化需要注意: - -```c -struct Student s = {1, 18}; //如果只写一半,那么只会初始化其中一部分数据,剩余的内容相当于没有初始值,跟数组是一样的 -struct Student s = {1, .name = "小红"}; //也可以指定去初始化哪一个属性 .变量名称 = 初始值 -``` - -那么现在我们拿到结构体变量之后,怎么去访问结构体内部存储的各种数据呢? - -```c -printf("id = %d, age = %d, name = %s", s.id, s.age, s.name); //结构体变量.数据名称 (这里.也是一种运算符) 就可以访问结构体中存放的对应的数据了 -``` - -是不是很简单?当然我们也可以通过同样的方式对结构体中的数据进行修改: - -```C -int main() { - struct Student s = {1, 18, "小明"}; - - s.name = "小红"; - s.age = 17; - - printf("id = %d, age = %d, name = %s", s.id, s.age, s.name); -} -``` - -那么结构体在内存中占据的大小是如何计算的呢?比如下面的这个结构体 - -```c -struct Object { - int a; - short b; - char c; -}; -``` - -这里我们可以借助`sizeof`关键字来帮助我们计算: - -```c -int main() { - printf("int类型的大小是:%lu", sizeof(int)); //sizeof能够计算数据在内存中所占据的空间大小(字节为单位) -} -``` - -![image-20220625220121753](https://s2.loli.net/2022/06/25/GvmlqIwNQn6Eszo.png) - -当然也可以计算变量的值占据的大小: - -```c -int main() { - int arr[10]; - printf("int arr[10]占据的大小是:%lu", sizeof (arr)); //在判断非类型时,sizeof 括号可省 -} -``` - -![image-20220625220323403](https://s2.loli.net/2022/06/25/yogRvUqtucjkYa7.png) - -同样的,它也能计算我们的结构体类型会占用多少的空间: - -```c -#include - -struct Object { - char a; - int b; - short c; -}; - -int main() { - printf("%lu", sizeof(struct Object)); //直接填入struct Object作为类型 -} -``` - -![image-20220625223336229](https://s2.loli.net/2022/06/25/evxSWPQGMZgEoaA.png) - -可以看到结果是8,那么,这个8字节是咋算出来的呢? - -> int(4字节)+ short(2字节)+ char(1字节) = 7字节(这咋看都算不出来12啊?) - -实际上结构体的大小是遵循下面的规则来进行计算的: - -* 结构体中的各个数据要求字节对齐,规则如下: - * **规则一:**结构体中元素按照定义顺序依次置于内存中,但并不是紧密排列的。从结构体首地址开始依次将元素放入内存时,元素会被放置在其自身对齐大小的整数倍地址上(0默认是所有大小的整数倍) - * **规则二:**如果结构体大小不是所有元素中最大对齐大小的整数倍,则结构体对齐到最大元素对齐大小的整数倍,填充空间放置到结构体末尾。 - * **规则三:**基本数据类型的对齐大小为其自身的大小,结构体数据类型的对齐大小为其元素中最大对齐大小元素的对齐大小。 - -这里我们以下面的为例: - -```c -struct Object { - char a; //char占据1个字节 - int b; //int占据4个字节,因为前面存了一个char,按理说应该从第2个字节开始存放,但是根据规则一,必须在自己的整数倍位置上存放,所以2不是4的整数倍位置,这时离1最近的下一个整数倍地址就是4了,所以前面空3个字节的位置出来,然后再放置 - short c; //前面存完int之后,就是从8开始了,刚好满足short(2字节)的整数倍,但是根据规则二,整个结构体大小必须是最大对齐大小的整数倍(这里最大对齐大小是int,所以是4),存完short之后,只有10个字节,所以屁股后面再补两个空字节,这样就可以了 -}; -``` - -![image-20220625224302673](https://s2.loli.net/2022/06/25/gpPDKMLw7z3GBOC.png) - -这样,就不难得出为什么结构体的大小是12了。 - -### 结构体数组和指针 - -前面我们介绍了结构体,现在我们可以将各种类型的数据全部安排到结构体中一起存放了。 - -不过仅仅只是使用结构体,还不够,我们可能需要保存很多个学生的信息,所以我们需要使用结构体类型的数组来进行保存: - -```c -#include - -struct Student { - int id; - int age; - char * name; -}; - -int main() { - struct Student arr[3] = {{1, 18, "小明"}, //声明一个结构体类型的数组,其实和基本类型声明数组是一样的 - {2, 17, "小红"}, //多个结构体数据用逗号隔开 - {3, 18, "小刚"}}; -} -``` - -那么现在如果我们想要访问数组中第二个结构体的名称属性,该怎么做呢? - -```c -int main() { - struct Student arr[3] = {{1, 18, "小明"}, - {2, 17, "小红"}, - {3, 18, "小刚"}}; - - printf("%s", arr[1].name); //先通过arr[1]拿到第二个结构体,然后再通过同样的方式 .数据名称 就可以拿到对应的值了 -} -``` - -当然,除了数组之外,我们可以创建一个指向结构体的指针。 - -```c -int main() { - struct Student student = {1, 18, "小明"}; - struct Student * p = &student; //同样的,类型后面加上*就是一个结构体类型的指针了 -} -``` - -我们拿到结构体类型的指针后,实际上指向的就是结构体对应的内存地址,和之前一样,我们也可以通过地址去访问结构体中的数据: - -```c -int main() { - struct Student student = {1, 18, "小明"}; - struct Student * p = &student; - - printf("%s", (*p).name); //由于.运算符优先级更高,所以需要先使用*p得到地址上的值,然后再去访问对应数据 -} -``` - -不过这样写起来太累了,我们可以使用简便写法: - -```c -printf("%s", p->name); //使用 -> 运算符来快速将指针所指结构体的对应数据取出 -``` - -我们来看看结构体作为参数在函数之间进行传递时会经历什么: - -```c -void test(struct Student student){ - student.age = 19; //我们对传入的结构体中的年龄进行修改 -} - -int main() { - struct Student student = {1, 18, "小明"}; - test(student); - printf("%d", student.age); //最后会是修改后的值吗? -} -``` - -![image-20220625232218359](https://s2.loli.net/2022/06/25/bAghYZQ9JtEfIud.png) - -可以看到在其他函数中对结构体内容的修改并没有对外面的结构体生效,因此,实际上结构体也是值传递。我们修改的只是另一个函数中的局部变量而已。 - -所以如果我们需要再另一个函数中处理外部的结构体,需要传递指针: - -```c -void test(struct Student * student){ //这里使用指针,那么现在就可以指向外部的结构体了 - student->age = 19; //别忘了指针怎么访问结构体内部数据的 -} - -int main() { - struct Student student = {1, 18, "小明"}; - test(&student); //传递结构体的地址过去 - printf("%d", student.age); -} -``` - -![image-20220625232826388](https://s2.loli.net/2022/06/25/km5Ov62CUJa7ITM.png) - -当然一般情况下推荐传递结构体的指针,而不是直接进行值传递,因为如果结构体非常大的话,光是数据拷贝就需要花费很大的精力,并且某些情况下我们可能根本用不到结构体中的所有数据,所以完全没必要浪费空间,使用指针反而是一种更好的方式。 - -### 联合体 - -联合体也可以在内部定义很多种类型的变量,但是它与结构体不同的是,所以的变量共用同一个空间。????啥意思? - -```c -union Object { //定义一个联合体类型唯一不同的就是前面的union了 - int a; - char b; - float c; -}; -``` - -我们来看看一个神奇的现象: - -```c -#include - -union Object { - int a; - char b; - float c; -}; - -int main() { - union Object object; - object.a = 66; //先给a赋值66 - printf("%d", object.b); //访问b -} -``` - -![image-20220625234018499](https://s2.loli.net/2022/06/25/y6gXHTaeBODFsYP.png) - -???? - -我修改的是a啊,怎么b也变成66了?这是因为它们共用了内存空间,实际上我们先将a修改为66,那么就将这段内存空间上的值修改为了66,因为内存空间共用,所以当读取b时,也会从这段内存空间中读取一个char长度的数据出来,所以得到的也是66。 - -```c -int main() { - union Object object; - object.a = 128; - printf("%d", object.b); -} -``` - -![image-20220625234747277](https://s2.loli.net/2022/06/25/hGDKQgMclqrZwIY.png) - -因为:128 = 10000000,所以用char读取后,由于第一位是符号位,于是就变成了-128。 - -那么联合体的大小又是如何决定的呢? - -```c -union Object { - int a; - char b; - float c; -}; - -int main() { - printf("%lu", sizeof(union Object)); -} -``` - -![image-20220625234931303](https://s2.loli.net/2022/06/25/ehHpAXPfYwZ7yBN.png) - -实际上,联合体的大小至少是其内部最大类型的大小,这里是int所以就是4,当然,当最大成员大小不是最大对齐数的整数倍的时候,就要对齐到最大对齐数的整数倍。 - -当然联合体的其他使用基本与结构体差不多,这里就不提了。 - -### 枚举 - -最后我们来看一下枚举类型,枚举类型一般用于表示一些预设好的整数常量,比如我们风扇有低、中、高三个档位,我们总是希望别人使用我们预设好的这三个档位,而不希望使用其他的档位,因为我们风扇就只设计了这三个档位。 - -这时我们就可以告诉别人,我们的风扇有哪几个档位,这种情况使用枚举就非常适合。在我们的程序中,只能使用基本数据类型对这三种档位进行区分,这样显然可读性不够,别人怎么知道哪个代表哪个档位呢?而使用枚举就没有这些问题了: - -```c -/** - * 比如现在我们设计: - * 1 = 低档位 - * 2 = 中档位 - * 3 = 高档位 - */ -enum status {low = 1, middle = 2, high = 3}; //enum 枚举类型名称 {枚举 = 初始值, 枚举...} -``` - -我们可以创建多个自定义名称的枚举,命名规则和变量差不多。我们可以当每一个枚举对应一个整数值,这样的话,我们就不需要去记忆每个数值代表的是什么档位了,我们可以直接根据枚举的名称来进行分辨,是不是很方便? - -使用枚举也非常地方便: - -```c -enum status {low = 1, middle = 2, high = 3}; - -int main() { - enum status a = low; //和之前一样,直接定义即可,类型为enum + 枚举名称,后面是变量名称,值可以直接写对应的枚举 - printf("%d", a); -} -``` - -```c -int main() { - enum status a = high; - if(a == low) { //判断起来就方便多了 - printf("低档位"); - } else if (a == high){ - printf("高档位"); - } else { - printf("中档位"); - } -} -``` - -当然也可以直接加入到`switch`语句中: - -```c -int main() { - enum status a = high; - switch (a) { - case low: - case high: - case middle: - default: ; - } -} -``` - -不过在枚举变量定义时需要注意: - -```c -enum status {low, middle, high}; //如果不给初始值的话,那么会从第一个枚举开始,默认值为0,后续依次+1 -``` - -所以这里的low就是0,middle就是1,high就是2了。 - -如果中途设定呢? - -```c -enum status {low, middle = 6, high}; //这里我们给middle设定为6 -``` - -这时low由于是第一个,所以还是从0开始,不过middle这里已经指定为6了,所以紧跟着的high初始值就是middle的值+1了,因此low现在是0,middle就是6,high就是7了。 - -### typedef关键字 - -这里最后还要提一下typedef关键字,这个关键字用于给指定的类型起别名。怎么个玩法呢? - -```c -typedef int lbwnb; //食用方式:typedef 类型名称 自定义类型别名 -``` - -比如这里我们给int起了一个别名,那么现在我们不仅可以使用int来表示一个int整数,而且也可以使用别名作为类型名称了: - -```c -#include - -typedef int lbwnb; - -int main() { - lbwnb i = 666; //类型名称直接写成别名,实际上本质还是int - printf("%d", i); -} -``` - -```c -typedef const char * String; //const char * 我们就起个名称为String表示字符串 - -int main() { - String str = "Hello World!"; //是不是有Java那味了 - printf(str); -} -``` - -当然除了这种基本类型之外,包括指针、结构体、联合体、枚举等等都可以使用这个关键字来完全起别名操作: - -```c -#include - -typedef struct test { - int age; - char name[10]; -} Student; //为了方便可以直接写到后面,当然也可以像上面一样单独声明 - -int main() { - Student student = {18, "小明"}; //直接使用别名,甚至struct关键字都不用加了 -} -``` - -在数据结构的学习总,typedef使用会更加地频繁。 - -*** - -## 预处理 - -虽然我们的C语言学习已经快要接近尾声了,但是有一个东西迟迟还没有介绍,就是我们一直在写的: - -```c -#include -``` - -这到底是个什么东西,为什么每次都要加上呢?这一部分,我们将详细讨论它缘由。 - -`#include`实际上是一种预处理指令,在我们的程序运行之前,会有一个叫做"C预处理器"的东西,根据我们程序中的预处理指令,预处理器能把对应的指令替换为指令想要表示的内容。我们先来看看`#include`做了什么。 - -### 文件包含 - -当预处理器发现`#include`指令时,会查看后面的文件名并把文件的内容包含到当前文件中,来替换掉`#include`指令。比如: - -```c -int main() { - printf("Hello World!"); //一个很普通的printf打印函数 -} -``` - -我们说了,这个函数是由系统为我们提供的函数,实际上这个函数实在其他源文件中定义好的,而定义这个函数的源文件,就是`stdio.h`,我们可以点进去看看: - -![image-20220626131600936](https://s2.loli.net/2022/06/26/OCd6iGrXkuZslpQ.png) - -除了`printf`之外,我们看到还有很多很多的函数原型定义,他们都写到这个源文件中,而这个文件并不是以`.c`结尾的,而是以`.h`结尾的,这种文件我们称为**头文件**。头文件一般仅包含定义一类的简单信息,只要能让编译器认识就行了。 - -而`#include`则是将这些头文件中提供的信息包含到我们的C语言源文件中,这样我们才能使用定义好的`printf`函数,如果我们不添加这个指令的话,那么会: - -![image-20220626132927056](https://s2.loli.net/2022/06/26/injLFga1oDvurJG.png) - -直接不认识了,`printf`是啥,好吃吗?说白了就是,我们如果不告诉编译器我们的这个函数是从哪来的,它怎么知道这个函数的具体定义什么是,程序又该怎么执行呢? - -`#include`的具体使用格式如下: - -```c -#include <文件名称> -``` - -当然也可以写成: - -``` -#include "文件名称" -``` - -这两种写法虽然都能引入头文件,但是区别还是有的: - -* **尖括号:**引用的是编译器的库路径里面的头文件。 -* **双引号:**引用的是程序目录中相对路径中的头文件,如果找不到再去上面的库里面找。 - -![image-20220626133419361](https://s2.loli.net/2022/06/26/IDFk4TvXq62g7Y8.png) - -可以看到系统已经为我们提供好了多种多样的头文件了,通过这些系统提供的库,我们就可以做很多的事情了。 - -当然我们也可以自己编写一个头文件,直接在项目根目录下创建一个新的C/C++头文件: - -```c -// -// Created by Nago Coler on 2023/6/26. -// - -#ifndef UNTITLED_TEST_H -#define UNTITLED_TEST_H - -#endif //UNTITLED_TEST_H -``` - -可以看到系统自动为我们生成好了这些内容,只不过现在还没学到(后面会介绍),现在直接删掉: - -```c -int test(int a, int b); -``` - -我们直接在头文件中随便声明一个函数原型,接着我们就可以引入这个头文件了: - -```c -#include -#include "test.h" //因为是我们自己项目目录中的,所以需要使用双引号 - -int main() { - int c = test(1, 2); //这样就可以使用头文件中声明的函数了 -} -``` - -通过导入头文件,我们就可以使用定义好的各种内容了,当然,不仅仅局限于函数。 - -不过现在还没办法执行,因为我们这里只是引入了头文件中定义的函数原型,具体的函数实现我们一般还是使用`.c`源代码文件去进行编写,这里我们创建一个同名的C源文件(不强制要求同名,但是这样看着整齐一点)去实现一下: - -```c -#include "test.h" //这里也需要把定义引入 - -int test(int a, int b) { //编写函数具体实现 - return a + b; -} -``` - -这样,我们再次运行程序就可以正确得到结果了: - -![image-20220626135746834](https://s2.loli.net/2022/06/26/L1z3sAJxWNIcQ7m.png) - -实际上预处理器正是通过头文件得到编译代码时所需的一些信息,然后才能把我们程序需要的内容(比如这里要用到的test函数)替换到我们的源文件中,最后才能正确编译为可执行程序。 - -比如现在我们要做一个学生管理库,这个库中提供了学生结构体的定义,以及对学生信息相关操作: - -```c -struct stu { //学生结构体定义 - int id; - int age; - char name[20]; -} typedef Student; - -void print(Student * student); //打印学生信息 -void modifyAge(Student * student, int newAge); //修改年龄 -void modifyId(Student * student, int newId); //修改学号 -``` - -```c -#include //函数具体实现源文件 -#include "student.h" - -void print(Student * student) { - printf("ID: %d, 姓名: %s, 年龄: %d岁\n", student->id, student->name, student->age); -} - -void modifyAge(Student * student, int newAge) { - student->age = newAge; -} - -void modifyId(Student * student, int newId) { - student->id = newId; -} -``` - -最后我们就可以愉快地使用了: - -```c -#include "student.h" - -int main() { - Student student = {1, 18, "小明"}; - modifyAge(&student, 19); - print(&student); //打印 -} -``` - -通过使用`#include`我们就可以将我们的项目拆分成多个模块去进行编写了。 - -### 系统库介绍 - -前面我们了解了如何使用`#include`引入其他文件,我们接着来了解一下系统为我们提供的一些常用库。实际上我们已经用过不少官方库提供的内容了: - -```c -#include - -int main() { - int a; - scanf("%d", &a); - printf("%d", a); - getchar(); - putchar('A'); - ... -} -``` - -包括前面我们在实战中用到了一次`string.h`中提供的计算字符串长度的函数: - -```c -#include -#include - -int main() { - char * c = "Hello World!"; - printf("%lu", strlen(c)); //使用strlen计算长度,注意返回值类型是size_t(别名而已,本质上就是unsigned long) -} -``` - -当然除了这个函数之外,实际上还有很多实用的字符串处理函数,都在这里定义了: - -```c -#include -#include - -int main() { - char a[20] = "Hello",* b = "World!"; //现在有两个字符串,但是我们希望把他们拼接到一起 - //注意不能这样写 char * a = "Hello",* b = "World!"; 如果直接用指针指向字符串常量,是无法进行拼接的,因为大小已经固定了 - //这里需要两个参数,第一个是目标字符串,一会会将第二个参数的字符串拼接到第一个字符串中(注意要装得下才行) - strcat(a, b); - printf("%s", a); -} -``` - -```c -int main() { - char str[10], * c = "Hello"; - strcpy(str, c); //使用cpy会直接将后面的字符串拷贝到前面的字符串数组中(同样需要前面装得下才行) - printf("%s", str); -} -``` - -```c -int main() { - char * a = "AAA", * b = "AAB"; - int value = strcmp(a, b); //strcmp会比较两个字符串,并返回结果 - printf("%d", value); -} -``` - -这里需要说一下的比较规则:把字符串str1和str2从首字符开始逐个字符的进行比较,直到某个字符不相同或者其中一个字符串比较完毕才停止比较,字符的比较按照ASCII码的大小进行判断。 - -比较完成后,会返回不匹配的两个字符的ASCII码之差: - -![image-20220626151419133](https://s2.loli.net/2022/06/26/3QXnw4jCflyziR5.png) - -我们接着来看用于处理数学问题的相关库: - -```c -#include -``` - -这里要用到`math.h`,它提供了我们场景的数学计算函数,比如求算术平方根、三角函数、对数等。 - -```c -#include -#include - -int main() { - int a = 2; - double d = sqrt(a); //使用sqrt可以求出非负数的算术平方根(底层采用牛顿逼近法计算) - printf("%lf", d); -} -``` - -![image-20220626152208591](https://s2.loli.net/2022/06/26/m6HWZqA4XCDvf3j.png) - -当然能够开根,也可以做乘方: - -``` c -int main() { - int a = 2; - double d = pow(a, 3); //使用pow可以快速计算乘方,这里求的是a的3次方 - printf("%lf", d); -} -``` - -有了这个函数,写个水仙花数更简单了: - -```c -int main() { - for (int i = 0; i < 1000; ++i) { - int a = i % 10, b = i / 10 % 10, c = i / 10 / 10; - if(pow(a, 3) + pow(b, 3) + pow(c, 3) == i) { - printf("%d 是水仙花数!\n", i); - } - } -} -``` - -当然也可以计算三角函数: - -```c -int main() { - printf("%f", tan(M_PI)); //这里我们使用正切函数计算tan180度的值,注意要填入的是弧度值 - //M_PI也是预先定义好的π的值,非常精确 -} -``` - -当然某些没有不存在的数可能算出来会得到一个比较奇怪的结果: - -```c -int main() { - printf("%f", tan(M_PI / 2)); //这里计算tan90°,我们知道tan90° = sin90°/cos90° = 1/0 不存在 -} -``` - -当然还有两个比较常用的函数: - -```c -int main() { - double x = 3.14; - printf("不小于x的最小整数:%f\n", ceil(x)); - printf("不大于x的最大整数:%f\n", floor(x)); -} -``` - -当然也有快速求绝对值的函数: - -```c -int main() { - double x = -3.14; - printf("x的绝对值是:%f", fabs(x)); -} -``` - -我们最后再来介绍一下通用工具库`stdlib`,这个库里面为我们提供了大量的工具函数: - -```c -#include -#include - -int main() { - int arr[] = {5, 2, 4, 0, 7, 3, 8, 1, 9, 6}; - //工具库已经为我们提供好了快速排序的实现函数,直接用就完事 - //参数有点多,第一个是待排序数组,第二个是待排序的数量(一开始就是数组长度),第三个是元素大小,第四个是排序规则(我们提供函数实现) - qsort(); -} -``` - -当然在开始使用之前我们还要先补充一点知识,我们发现`qsort`的原型定义,使用的是void类型的指针。 - -怎么void还有指针呢?void不是空吗? - -> void 指针是一种特殊的指针,表示为“无类型指针”,由于 void 指针没有特定的类型,因此它可以指向任何类型的数据。也就是说,任何类型的指针都可以直接赋值给 void 指针,而无需进行其他相关的强制类型转换。 - -所以这里之所以需要void指针,其实就是为了可以填入任何类型的数组,而我们发现第三个参数实际上就是因为是void指针不知道具体给进来的类型是什么,所以需要我们来告诉函数我们使用的类型所占大小是多少。 - -而最后一个参数实际上就是我们前面介绍的函数回调了,因为函数不知道你的比较规则是什么,是从小到大还是从大到小呢?所以我们需要编写一个函数来对两个待比较的元素进行大小判断。 - -好了,现在了解了之后,我们就可以开始填入参数了: - -```c -#include -#include - -int compare(const void * a, const void * b) { //参数为两个待比较的元素,返回值负数表示a比b小,正数表示a比b大,0表示相等 - int * x = (int *) a, * y = (int *) b; //这里因为判断的是int所以需要先强制类型转换为int *指针 - return *x - *y; //其实直接返回a - b就完事了,因为如果a比b大的话算出来一定是正数,反之同理 -} - -int main() { - int arr[] = {5, 2, 4, 0, 7, 3, 8, 1, 9, 6}; - //工具库已经为我们提供好了快速排序的实现函数,直接用就完事 - //参数有点多,第一个是待排序数组,第二个是待排序的数量(一开始就是数组长度),第三个是元素大小,第四个是排序规则(我们提供函数实现) - qsort(arr, 10, sizeof(int), compare); - - for (int i = 0; i < 10; ++i) { - printf("%d ", arr[i]); - } -} -``` - -这样,我们就可以对数组进行快速排序了。 - -当然工具库中还提供了`exit`函数用于终止程序: - -```c -#include - -int main() { - exit(EXIT_SUCCESS); //直接终止程序,其中参数是传递给父进程的(但是现在我们只是简单程序) -} -``` - -不过乍一看,貌似和我直接在main里面return没啥区别,反正都会结束。 - -当然还有两个我们会在后续学习数据结构中用的较多的函数: - -```c -int main() { - int * p = (int *) malloc(sizeof(int)); //我们可以使用malloc函数来动态申请一段内存空间 - //申请后会返回申请到的内存空间的首地址 - *p = 128; - printf("%d", *p); -} -``` - -> malloc用于向系统申请分配指定size个字节的内存空间,返回类型是 void * 类型,如果申请成功返回首地址,如果失败返回NULL空地址(比如系统内存不足了就可能会申请失败) - -申请到一段内存空间后,这段内存空间我们就可以往上面随便读写数据了,实际上就是和变量一样,只不过这个内存空间是我们自主申请的,并不是通过创建变量得到的,但是使用上其实没啥大的区别。 - -不过要注意,这段内存使用完之后记得清理,就像函数执行完会自动销毁其中的局部变量一样,如果不清理那么这段内存会被一直占用: - -```c -int main() { - int * p = (int *)malloc(sizeof(int)); - *p = 128; - printf("%d", *p); - - free(p); //使用free函数对内存空间进行释放,归还给系统,这样这段内存又可以被系统分配给别人用了 - p = NULL; //指针也不能再指向那个地址了,因为它已经被释放了 -} -``` - -内存资源是很宝贵的(不像硬盘几个T随便用,我们的电脑可能32G的内存都算高配了),不能随便浪费,所以一般情况下malloc和free都是一一对应的,这样才能安全合理地使用内存。 - -### 宏定义 - -我们前面认识了`#include`指令,我们接着来看`#define`指令,它可以实现宏定义。我语文不好,宏是啥意思? - -![image-20220626164426525](https://s2.loli.net/2022/06/26/3CHdqbsAji78KwN.png) - -> 把参数批量替换到文本中,这种实现通常称为宏(macro)或定义宏 (define macro) - -我们可以通过`#define`来定义宏,规则如下: - -``` -#define 宏名(记号) 内容 -``` - -比如现在我们想通过宏定义一个PI: - -```c -#define PI 3.1415926 -``` - -这样就可以了,那么怎么去使用它呢? - -```c -#include - -#define PI 3.1415926 - -int main() { - printf("π的值为:%f", PI); //就像使用变量一样,我们可以直接将PI放到这个位置 -} -``` - -在编译时,预处理程序会进行宏替换操作,也就是将程序中所有的`PI`全部替换为`3.1415926`,注意这个跟类型无关,是赤裸裸的纯文本替换,也就是相当于把我们的代码修改了,PI那里直接变成`3.1415926`,当然如果你定义为其他任意的内容,同样会替换到那个位置,但是至于替换之后程序还正不正常就不知道了。 - -我们通过下面这个例子来加深对文本替换这句话的理解: - -```c -#include - -#define M a + b - -int main() { - int a = 10, b = 20; - printf("%d", M * a); //各位觉得计算结果会是多少呢? -} -``` - -如果按照我们的正常思维,M是a+b,那么替换上去之后应该就是30了吧?然后30 x 10最后得到的应该是300才对。 - -![image-20220626165518162](https://s2.loli.net/2022/06/26/ZOwauBAUCTscXYK.png) - -不过最后貌似并不是这样的,怎么会算出来是210的呢? - -实际上还是那句话,在编译时仅仅是做了文本替换,相当于最后我们的代码是: - -```c -printf("%d", a + b * a); -``` - -所以先计算的是a x b然后再加a,最后结果就是210了。 - -当然任何地方都可以使用宏替换,包括类型,反正最后都会变成被替换的内容: - -```c -#define lbwnb long - -int main() { - lbwnb a = 10L; -} -``` - -当然除了这种简单的替换之外我们还可以添加参数,就像函数那样: - -```c -#include - -#define MUL(x) x * x - -int main() { - printf("%d", MUL(9)); -} -``` - -虽然这里搞得像函数一样,但是最后还是会被替换为x * x,而这个x就是我们填写的参数,所以最后会变成 9 * 9 替换上去,程序运行出来的结果就是81了。 - -直接调函数肯定也是没问题的,反正就纯替换: - -```c -#include - -#define bb(i) printf("我是宏替换的:%d", i); - -int main() { - bb(666); -} -``` - -那要是我想在字符串里面加一个宏定义中的参数呢? - -```c -#include - -#define bb(str) printf("我是宏替换的:"#str" <"); //使用#直接在字符串中进行宏替换,否则默认情况下会被当做一个字符 - -int main() { - bb("你看这不就替换了吗"); -} -``` - -当然还可以替换宏中的部分: - -```c -#define TEST(n) x ##n //##会使用参数进行拼接 - -int main() { - int TEST(1) = 10; //这里传入1,那么实际上就是被替换为x1 - x1 = 20; //所以上面其实是int x1 = 10 -} -``` - -宏既然可以定义出来,那么也可以取消定义,我们可以使用`#undef`来取消已有的宏定义: - -![image-20220626172208060](https://s2.loli.net/2022/06/26/ZA1j7dE2pKMXuVn.png) - -可以看到在使用`#undef`之后,直接不认识了。 - -当然除了我们自己可以去定义之外,系统也为我们提供了一些预定义的宏: - -| 宏名称 | 含义 | -| ------------ | ------------------------------------------- | -| _ _ DATE _ _ | 当前的日期,格式为类似 Jun 27 2023 的字符串 | -| _ _ TIME _ _ | 当前的时间,格式为类似 10:23:12 的字符串 | -| _ _ FILE _ _ | 当前源代码文件的名称(含路径)的字符串 | -| _ _ LINE _ _ | 当前所处的行号是多少就替换为多少,整数 | - -这里只列出了一部分。 - -### 条件编译 - -我们来看看条件编译,我们还可以根据条件,选择性地对某些内容进行忽略。 - -收我们我们来认识一下`#ifdef`、`#else`、`#endif`这三种条件编译指令: - -```c -#include - -#ifdef PI //ifdef用于判断是否定义了符号PI,如果没有的话则处理以下的指令 - #define M 666 -#else //如果定义了符号PI,那么就处理这个分支的语句 - #define M 777 -#endif //最后需要以endif指令结束整个判断 - -int main() { - printf("%d", M); //最后打印M -} -``` - -![image-20220626184356031](https://s2.loli.net/2022/06/26/U7r6g5pB21fISVh.png) - -可以看到,在我们没有定义PI的情况下,执行的是`#define M 777`,那要是现在定义了呢?我们编写一个新的头文件: - -```c -#define PI 3.1415 -``` - -现在我们引入这个头文件,那么对应的预编译指令也会跟着包含进来: - -```c -#include -#include "test.h" - -#ifdef PI - #define M 666 -#else - #define M 777 -#endif - -int main() { - printf("%d", M); -} -``` - -![image-20220626184248768](https://s2.loli.net/2022/06/26/ojZNauDIb4ckylC.png) - -可以看到此时得到的结果就是666了,因为现在PI在引入的头文件中已经定义了(当然直接在当前源文件中定义也是一样的) - -那如果我现在希望判断某个符号没定义呢?没错,还有`#ifndef`表示判断是否未定义某个符号: - -```c -#include - -#ifndef PI //ifndef 就是 if not define,跟ifdef反着的 - #define M 666 -#else - #define M 777 -#endif - -int main() { - printf("%d", M); -} -``` - -![image-20220626184747886](https://s2.loli.net/2022/06/26/br2lo1vj5GPIZig.png) - -当然,除了判断某个符号是否存在之外,我们也可以像条件语句那样直接进行逻辑判断,这里需要使用到`#if`和`#elif`指令: - -```c -#define M 666 - -#if M == 666 //若M等于666那么定义K = 999 - #define K 999 -#elif M == 777 //等同于else if语句 - #define K 888 -#else //else语句 - #define K 000 -#endif -``` - -并且这些分支还支持嵌套使用: - -```c -#define M 666 - -#if M == 666 - #ifdef L - #include "test.h" - #endif -#elif M == 777 - #define K = 888 -#else - #define K = 000 -#endif -``` - -*** - -## 文件输入/输出(选学) - -**注意:**本小节作为选学内容,不强制要求。 - -我们的电脑上其实存放了多种多样的文件,比如我们办公经常需要打交道的Word文档、PPT幻灯片、Excel表格等,包括我们的C程序源文件,图片、视频等等,这些都是文件,由于文件需要被长期保存,所以它们被统一存放到我们电脑上的硬盘中。硬盘不像内存,虽然它们都可以存放数据,但是内存中的数据断电即失(在学习完数字电路中的锁存器后,你就知道为什么了)而硬盘却支持长期保存数据,当然也是以二进制的形式进行保存的。 - -### 文本读写操作 - -现代计算机使用的硬盘大致分为固态硬盘和机械硬盘两种,其中固态硬盘的读写速度远超机械硬盘,但是寿命(硬盘是有读写次数限制的,如果读写次数超标,那么就无法使用了)不如机械硬盘,所以一般重要数据都是在机械硬盘中存放,而系统文件一般是在固态硬盘中存放,这样电脑的启动速度会很快。 - -不过文件并不是随便在硬盘中进行保存的,而是根据不同的文件系统按规则进行存放的,比如Windows下采用的就是NTFS文件系统,而MacOS采用的是APFS文件系统。 - -> 文件系统是操作系统用于明确存储设备(常见的是磁盘,也有基于NAND Flash的固态硬盘)或分区上的文件的方法和数据结构;即在存储设备上组织文件的方法。 - -其中某些文件是以文本格式存储的,比如我们的C语言源文件、普通的文本文档等;而有些文件是二进制格式,比如图片、视频、应用程序等,但是他们最终都是以二进制的形式存储到硬盘上的。当然,普通的文本文件我们直接打开记事本都可以直接进行编辑,而图片这类二进制文件,需要使用专门读取图片的软件来查看,根据格式的不同(图片有png、jpg等格式)对文件的解读方式也不一样,但是最后都会被专门的图片查看软件展示出来。 - -通过使用C语言,我们也可以读取硬盘上的文件,这里我们先创建一个简单的文本文件: - -![image-20220628112153835](https://s2.loli.net/2022/06/28/fcwle59DIBt1WUQ.png) - -接着我们可以使用stdio.h中为我们提供的函数打开一个文件: - -```c -#include - -int main() { - FILE * file = fopen("hello.txt", "rw"); //使用fopen函数来打开一个文件 -} -``` - -这里我们先来介绍一下参数: - -* 第一个参数:文件的名称,这里我填写的是相对路径,也可以写成绝对路径 -* 第二个参数:打开文件的模式,其中模式有以下这些: - -| 模式字符串 | 含义 | -| :------------------------------------: | :----------------------------------------------------------: | -| “r” | 以读模式打开文件 | -| “w” | 以写模式打开文件,把现有文件的长度截为0,如果文件不存在,则创建一个新文件 | -| “a” | 以写模式打开文件,在现有文件末尾添加内容,如果文件不存在,则创建一个新文件 | -| “r+” | 以更新模式打开文件(即可以读写文件)该文件必须存在 | -| “w+” | 以更新模式打开文件(即可以读写文件),如果文件存在,则将其长度截为0;如果文件不存在,则创建一个新文件 | -| “a+” | 以更新模式打开文件(即,读写),在现有文件的末尾添加内容,如果文件不存在则创建一个新文件;可以读整个文件,但是只能从末尾添加内容 | -| “rb”,“wb”,“ab”,“ab+”,“a+b”,“wb+”,“w+b” | 与“a+”模式类似,但是以二进制模式打开文件而不是以文本模式打开文件 | - -具体的不同打开模式会影响到后续的操作,我们后面再说。这里我们使用r表示可读。 - -然后这个函数返回的是一个FILE结构体指针: - -```c -typedef struct __sFILE { - unsigned char *_p; /* current position in (some) buffer */ - int _r; /* read space left for getc() */ - ... -} FILE; -``` - -定义非常复杂,这里我们就不详细介绍了,这样我们就成功打开了这个文件,那么如何对文件进行读取操作呢? - -我们可以使用`getc`来快速读取文件中的字符: - -```c -#include - -int main() { - FILE * file = fopen("hello.txt", "r"); - int c; - while ((c = getc(file)) != EOF) { //通过一个while循环来不断读取文件,使用getc从文件中读取一个字符,如果到末尾了,那么会返回一个特殊值EOF - putchar(c); //使用putchar来快速打印字符到控制台 - } -} -``` - -可以看到成功输出: - -![image-20220628135052152](https://s2.loli.net/2022/06/28/e1QFxCszVM4BX2f.png) - -当然如果没有这个文件或是文件打开失败的话,可能会返回一个空指针,所以我们需要进一步判断: - -```c -#include - -int main() { - FILE * file = fopen("hello.txt", "r"); - if(file != NULL) { //如果打开失败会返回NULL - int c; - while ((c = getc(file)) != EOF) { - putchar(c); - } - } else{ - puts("文件打开失败!"); - } -} -``` - -最后我们在使用完文件后,记得关闭文件来释放资源,不然一直会被占用: - -```c -fclose(file); //fclose用于关闭文件 -``` - -那么读取文件我们知道了,写入呢?写入我们同样可以使用`putc`来完成: - -```c -#include - -int main() { - FILE * file = fopen("hello.txt", "w"); //注意这里需要修改为写模式 - if(file != NULL) { - for (int i = 0; i < 10; ++i) - putc('A' + i, file); //从A开始依次写入10个字符 - fclose(file); - } else{ - puts("文件打开失败!"); - } -} -``` - -可以看到最后我们的文件变成了: - -![image-20220628135806896](https://s2.loli.net/2022/06/28/e2qC9WvzMlIBubA.png) - -原来的文本被覆盖为了我们输入的新文本,那要是我们现在不想覆盖原来的,而是希望在后面追加输入呢? - -```c -FILE * file = fopen("hello.txt", "a"); //我们可以将其修改为a表示append追加输入 -``` - -这样就不会覆盖原有内容而是追加填写了: - -![image-20220628135946686](https://s2.loli.net/2022/06/28/hkQmsNJxgucpYfl.png) - -不过这里要补充一下,文件的读写实际上并不是直接对文件进行操作的,在这之间还有一个缓冲区: - -![image-20220628141209105](https://s2.loli.net/2022/06/28/jpu8XIhnsxQScAD.png) - -我们所有的读操作,首先是从文件读取到缓冲区中,再从缓冲区中读取到程序中的;写操作就是先写入到缓冲区,然后再从缓冲区中写入到文件中。这样做的目的是,因为内存和硬盘的速度差距有点大,为了解决这种速度差异导致的性能问题,所以设定一个缓冲区,这样就算速度不一样,但是内容被放在缓冲区中慢慢消化就没问题了。 - -虽然缓冲区能够解决这些问题,但是也会带来一些不便之处,比如下面的例子: - -```c -#include - -int main() { - FILE * file = fopen("hello.txt", "a+"); //注意这里需要修改为写模式 - if(file != NULL) { - while (1) { - int c = getchar(); //不断从控制台读取字符 - if(c == 'q') break; - putc(c, file); //写入到文件中 - } - fclose(file); - } -} -``` - -我们发现当我们敲了一个字符之后,可能并不会马上更新到文件中,这就是由于缓冲区没有及时同步到文件中,所以我们需要调用一个函数来刷新缓冲区,将那些缓冲区的没有同步的数据全部同步到文件中: - -```c -#include - -int main() { - FILE * file = fopen("hello.txt", "a+"); - if(file != NULL) { - while (1) { - int c = getchar(); - if(c == 'q') break; - putc(c, file); - fflush(file); //使用fflush来刷新缓冲区 - } - fclose(file); - } -} -``` - -这样我们就可以看到输入一个字符马上就能同步更新了。当然我们也可以手动设定缓冲区的大小: - -```c -char buf[3]; -setvbuf(file, buf, _IOFBF, 3); -``` - -其中: - -* _IONBF:表示不使用缓冲区 -* _IOFBF:表示只有缓冲区填满了才会更新到文件 -* _IOLBF:表示遇到换行就更新到文件 - -除了使用`getc`之外,标准库中还提供了`fprintf`和`fgets`系列函数: - -```c -#include - -int main() { - FILE * file = fopen("hello.txt", "a+"); - if(file != NULL) { - fprintf(file, "树脂%d", 666); //fprintf就像普通的打印一样,但是它并不是打印到控制台,而是文件中 - fclose(file); - } -} -``` - -```c -#include - -int main() { - FILE * file = fopen("hello.txt", "w"); - if(file != NULL) { - fputs("小黑子苏珊", file); //就像使用puts一样,同样是输出到文件中 - fclose(file); - } -} -``` - -这样,对于文本文件的基础读写操作就讲解到这里。 - -### 随机访问 - -前面我们介绍了文本文件的基础读写操作,我们接着来看随机访问。首先什么是随机访问? - -我们在前面读取文件时,实际上是按照顺序,每次读取都会往后移动一个字符继续读取,那么如果现在我希望直接跳到某个位置进行读取是否可以实现呢? - -我们可以使用fseek来跳转到指定位置: - -```c -#include - -int main() { - FILE * file = fopen("hello.txt", "r"); - if(file != NULL) { - fseek(file, -2L, SEEK_SET); //第二个参数为偏移量,根据后面的参数而定 - putchar(getc(file)); - fclose(file); - } -} -``` - -这里介绍一下起始点: - -* SEEK_SET:从文件开始处开始 -* SEEK_CUR:从当前位置开始(就是已经读到哪个位置就是哪个位置) -* SEEK_END:从文件末尾开始 - -而上面的使用的是SEEK_SET,那么就是从文件开始,往后偏移2个字符的位置,也就是字符`l`。 - -那么我们怎么知道当前已经读取到文件第几个字符了呢? - -```c -#include - -int main() { - FILE * file = fopen("hello.txt", "r"); - if(file != NULL) { - fseek(file, 2L, SEEK_SET); - printf("%ld", ftell(file)); //可以使用ftell来直接返回当前位置,返回类型为long - fclose(file); - } -} -``` - -当然除了fseek和ftell之外,还有fgetpos和fsetpos这两个函数,它们也可以获取位置和设定位置: - -```c -#include - -int main() { - FILE * file = fopen("hello.txt", "r"); - if(file != NULL) { - fpos_t pos; //位置需要使用fpos_t进行存储(主要用于处理大文件) - fgetpos(file, &pos); //获取位置,并设定给pos,此时位置为0 - fseek(file, -2L, SEEK_END); //通过fseek移动到倒数第二个位置 - fsetpos(file, &pos); //设定位置为pos位置 - printf("%ld", ftell(file)); //最后得到的就是经过fsetpos设定的新位置了 - fclose(file); - } -} -``` - -了解了这些函数,这样我们就可以实现对文件的随机读写了。 - -前面我都是对文本文件进行操作,我们接着来看如何直接读写二进制文件,比如现在我们想要复制一个文件: - -```c -#include - -int main() { - FILE * file = fopen("hello.txt", "r"); - FILE * target = fopen("hello2.txt", "w"); - if(file != NULL) { - char buf[1024]; //这里我们使用char类型的数组作为暂存 - size_t s; - while ((s = fread(buf, sizeof(char), 1024, file)) > 0) { //使用fread函数进行读取,每次都会从文件中读取指定大小的数据到暂存数组中,返回值为实际读取的值,如果读取的值小于0表示读完了 - fwrite(buf, sizeof(char), s, target); //使用fwrite将数据写入到指定文件中 - } - fclose(file); - } -} -``` - -![image-20220628151553683](https://s2.loli.net/2022/06/28/sHcBxRUChQAVqL5.png) - -可以看到我们成功将hello.txt中的内容复制到另一个文本文件中了。当然我们也可以用来拷贝大型文件: - -```c -#include - -int main() { - FILE * file = fopen("22000.318.211104-1236.co_release_svc_refresh_CLIENTCONSUMER_RET_A64FRE_zh-cn.iso", "r"); - FILE * target = fopen("22000.318.211104-1236.co_release_svc_refresh_CLIENTCONSUMER_RET_A64FRE_zh-cn-2.iso", "w"); - - if(file != NULL) { - //计算文件的大小 - fseek(file, 0L, SEEK_END); - long size = ftell(file); - fseek(file, 0L, SEEK_SET); - - char buf[1024 * 1024]; - size_t s, all = 0; - while ((s = fread(buf, sizeof(char), 1024, file)) > 0) { - fwrite(buf, sizeof(char), s, target); - all += s; - printf("当前进度 %.1f%%\n", (double) all / (double) size * 100); - } - fclose(file); - } -} -``` - -是不是感觉有内味了: - -![image-20220628152934462](https://s2.loli.net/2022/06/28/Ny4LqHeDlGOQiVd.png) - -这样我们就实现了文件的拷贝。 - -*** - -## 程序编译和调试(选学) - -**注意:**本小节作为选学内容,不强制要求。 - -有关C语言语言层面的教学基本就结束了,最后让我们来了解一下如何不借助IDE,通过最原始的方式手动完成程序的编译。 - -### C语言程序的编译 - -在开始之前,我们需要介绍一个编译器: - -> GCC原名为GNU C语言编译器(GNU C Compiler),只能处理C语言。但其很快扩展,变得可处理C++,后来又扩展为能够支持更多编程语言,如Fortran、[Pascal](https://baike.baidu.com/item/Pascal/241171)、Objective -C、Java、Ada、Go以及各类处理器[架构](https://baike.baidu.com/item/架构/13004195)上的[汇编语言](https://baike.baidu.com/item/汇编语言/61826)等,所以改名GNU编译器套件(GNU Compiler Collection) - -那么gcc编译器是如何将我们的程序一步步编译为可执行文件的呢? - -![image-20220627112630649](https://s2.loli.net/2022/06/27/rLjZ5RQtqEvSlXC.png) - -1. 预处理(Pre-Processing):首先会经过预处理器将程序中的预编译指令进行处理,然后把源文件中的注释这些没用的东西都给扬了。 -2. 编译(Compiling):处理好之后,就可以正式开始编译,首先会编译为汇编代码。 -3. 汇编(Assembling):接着就该将汇编代码编译为机器可以执行的二进制机器指令了,会得到一个二进制目标文件。 -4. 链接(Linking):最后需要将这个二进制目标文件与系统库和其他库的OBJ文件、库文件链接起来,最终生成了可以在特定平台运行的可执行文件。 - -比如在Windows操作系统下完成这四步,就会生成一个Windows的.exe可执行文件。 - -我们来一步一步尝试一下,首先我们把CLion自带的GCC工具目录配置到环境变量中(Mac系统直接自带,不需要任何配置): - -![image-20220627120949262](https://s2.loli.net/2022/06/27/uAR3aQhylOGjBf8.png) - -位置在你的`CLion安装目录/bin/mingw/bin`,打开高级系统设置,添加环境变量: - -![image-20220627121332125](https://s2.loli.net/2022/06/27/qKQgJy1C5uetj3N.png) - -配置完成后,打开CLion,我们随便编写一点内容: - -```c -#include - -int main() { - printf("Hello, World!\n"); - return 0; -} -``` - -然后我们点击IDE下方的终端面板: - -![image-20220627130234208](https://s2.loli.net/2022/06/27/rhbZTmKvqzYtPgo.png) - -可以看到这里打开的是Windows自带的PowerShell终端,如果不是的可以在设置中修改: - -![image-20220627130343946](https://s2.loli.net/2022/06/27/5clxwIeSszoUpZ9.png) - -现在我们就可以手动开始对我们的C源文件进行编译了,首先是第一步,我们需要对源文件进行预处理: - -```sh -gcc -E main.c -o main.i -``` - -其中 `-E` 后面的是我们的源文件名称,`-o` 是我们预处理后生成的文件名称: - -![image-20220627130740318](https://s2.loli.net/2022/06/27/pEC9mZwql5X1JMj.png) - -生成后,我们可以直接查看这个文件(因为此时依然是普通文本)可以看到,我们的代码在经过预处理之后,`#include `中的内容都替换过来了。最下面大约1000行左右的位置就是我们的代码了: - -![image-20220627131124121](https://s2.loli.net/2022/06/27/8vk3rEjcy4XK1tO.png) - -现在我们已经完成了预处理,接着就可以将其编译为汇编程序了: - -```sh -gcc -S main.i -o main.s -``` - -这里的`-S`就是预处理之后的文件,我们可以直接将其编译为汇编代码: - -![image-20220627131513884](https://s2.loli.net/2022/06/27/Y4a6LUSwjKl8IBJ.png) - -可以看到这里都是汇编代码,各种各样的汇编指令。接着我们就可以将这个汇编代码继续编译为二进制文件了: - -```sh -gcc -c main.s -o main.o -``` - -这里`-c`后的就是我们的汇编程序,直接生成为二进制文件: - -![image-20220627131829386](https://s2.loli.net/2022/06/27/vR8NOKBfrPoCjbp.png) - -不过现在我们还没办法直接运行它,因为还需要进一步链接,变成Windows操作系统可以执行的程序: - -```sh -gcc main.o -o main -``` - -这里直接将刚刚生成的目标文件编译为可执行文件,我们就可以直接运行了: - -![image-20220627132110465](https://s2.loli.net/2022/06/27/GqwY5r7s6b8pvHd.png) - -![image-20220627132259302](https://s2.loli.net/2022/06/27/Yne5hmAvOQkTBMF.png) - -成功生成.exe文件,我们直接在控制台输入它的名字就可以运行了: - -![image-20220627132221190](https://s2.loli.net/2022/06/27/YU8k3wyMg2WF9VO.png) - -这样我们就实现了手动编译一个C语言程序。当然如果我们要更快速一点地完成编译,可以直接将源文件进行编译: - -```sh -gcc main.c -o main -``` - -当然这种只是简单的单源文件下的编译,要是遇到多文件的情况下呢? - -![image-20220627133429263](https://s2.loli.net/2022/06/27/acsCO9gFyNUXh4S.png) - -```c -void swap(int * a, int * b); -``` - -```c -#include "test.h" - -void swap(int * a, int * b) { - int tmp = *a; - *a = *b; - *b = tmp; -} -``` - -```c -#include -#include "test.h" - -int main() { - int arr[] = {4, 2, 1, 9, 5, 0, 3, 8, 7, 6}; - - for (int i = 0; i < 9; ++i) { - for (int j = 0; j < 9 - i; ++j) { - if(arr[j] > arr[j + 1]) swap(&arr[j], &arr[j + 1]); - } - } - - for (int i = 0; i < 10; ++i) { - printf("%d ", arr[i]); - } -} -``` - -我们还是按照刚刚的方式直接进行编译: - -![image-20220627133646126](https://s2.loli.net/2022/06/27/jlK9tZRFgxIw4Qr.png) - -可以看到,编译错误,无法识别到`swap`这个函数,说明肯定还需要把引入的其他文件也给一起带上,所以: - -```sh -gcc main.c test.c -o main -``` - -或是将两个文件单独编译为对应的二进制文件,最后再放到一起编译也是可以的: - -```sh -gcc main.o test.o -o main -``` - -OK,现在多个文件就可以在一起编译了,最后同样生成了一个可执行文件: - -![image-20220627134010138](https://s2.loli.net/2022/06/27/bQOiG2US6wFDnxy.png) - -### 使用Make和CMake进行构建 - -我们的项目可能会有很多很多的内容需要去进行编译,如何去进行组织成了一个大问题,比如让谁先编译,谁后编译,这时,我们就需要一个构建工具来帮助我们对程序的构建流程进行组织。 - -> Make是最常用的构建工具,诞生于1977年,主要用于C语言的项目。但是实际上 ,任何只要某个文件有变化,就要重新构建的项目,都可以用Make构建。 - -要使用Make对我们的项目进行构建,我们需要先告诉Make我们的程序应该如何去进行构建,这时我们就要编写一下Makefile了: - -![image-20220627135232863](https://s2.loli.net/2022/06/27/3bRneOcMtfZyPw7.png) - -我们只需要把需要执行的命令按照我们想要的顺序全部写到里面就可以了,但是需要遵循以下格式: - -```makefile -targets : prerequisites - command -``` - -一个Makefile中可以有很多个目标,比如我们现在要分别编译main.c和test.c,那么就需要创建两个目标: - -* targets:构建的目标,可以是一个普通的标签、文件名称等 -* prerequisites:前置条件,可以设定要求完成其他目标才能开始构建当前目标 -* command:构建需要执行的命令 - -比如现在我们想要分别先编译test.c和main.c,最后将他们变成一个可执行文件,那么makefile可以这样编写: - -```makefile -main.exe: test.o main.o #目标1:构建最终的程序,要求完成下面两个目标(注意最终目标需要写在第一个) - gcc test.o main.o -o main - -main.o: main.c #目标2:构建目标为main.o,前置要求必须有main.c文件 - gcc -E main.c -o main.i - gcc -S main.i -o main.s - gcc -c main.s -o main.o - -test.o: test.c #目标3:同样的,要求必须有test.c文件才能开始 - gcc -E test.c -o test.i - gcc -S test.i -o test.s - gcc -c test.s -o test.o -``` - -接着我们只需要在控制台输入make命令(CLion自带环境需要输入mingw32-make命令,Mac下直接输入make)就可以进行编译了: - -![image-20220627212727506](https://s2.loli.net/2022/06/27/9nGRvqp8SjUXhN7.png) - -命令执行的每一步都会详细打印出来,我们可以看到构建确实是按照我们的顺序在进行,并且成功编译出最终目标: - -![image-20220627212822806](https://s2.loli.net/2022/06/27/FTILC5evZ67rEXo.png) - -当然,如果我们没有做任何的修改,那么再次执行make命令不会做任何事情: - -![image-20220627212951245](https://s2.loli.net/2022/06/27/BQSIXTpvOc9Jukh.png) - -但是如果我们修改一下源文件的话,执行make将会重新构建目标: - -![image-20220627213029819](https://s2.loli.net/2022/06/27/sfQgxm34vlw8TKi.png) - -再次执行: - -![image-20220627213051247](https://s2.loli.net/2022/06/27/SrE9FcADYWaywRN.png) - -通过使用Make,即使没有如此高级的IDE,哪怕我们纯靠记事本写C代码,都可以很方便地完成对一个项目的构建了。当然这只是Make的简单使用,它还支持使用变量、逻辑判断等高级玩法,这里我们就不多做介绍了。 - -虽然使用Make可以很方便地对项目构建流程进行配置,但是貌似CLion并没有采用这种方式来组织我们的项目进行构建,而是使用了CMake,我们来看看它又是做什么的。 - -> CMake是一个跨平台的安装([编译](https://baike.baidu.com/item/编译/1258343))工具,可以用简单的语句来描述所有平台的安装(编译过程)。他能够输出各种各样的makefile或者project文件,能测试[编译器](https://baike.baidu.com/item/编译器/8853067)所支持的C++特性,类似[UNIX](https://baike.baidu.com/item/UNIX/219943)下的automake。 - -简而言之, CMake是一个跨平台的Makefile生成工具! - -实际上当我们创建一个项目后,CLion会自动为我们配置CMake,而具体的配置都是写在CMakeList.txt中的: - -```cmake -cmake_minimum_required(VERSION 3.22) -project(untitled C) - -set(CMAKE_C_STANDARD 99) - -add_executable(untitled main.c test.c test.h) -``` - -我们逐行来进行解读: - -* 第一行使用cmake_minimum_required来指定当前项目使用的CMake最低版本,如果使用的CMake程序低于此版本是无法构建的。 -* 第二行project指定项目名称,名称随意,后面的是项目使用的语言,这里是C。 -* 第三行set用于设定一些环境变量等,这里设定的是C 99标准。 -* 第四行add_executable用于指定一个编译出来的可执行文件,这里名称为untitled,后面的都是需要编译的源文件(头文件可以不写) - -当然除了这些语法之外,还有各种各样的设定,比如设定库目录或是外部动态连接库等,这里就不多说了,感兴趣的可以自行了解。 - -这里我们来手动执行一下cmake: - -![image-20220627215908039](https://s2.loli.net/2022/06/27/XFpHB1aPbRCA4kL.png) - -首先还是添加环境变量,添加完成后重启CLion,我们输入cmake命令进行生成: - -```sh - cmake -S . -B test -G "MinGW Makefiles" -``` - -其中`-S`后面的是源文件目录,这里`.`表示当前目录,`-B`后面是构建目录,一会构建的文件都在这里面存放,最后`-G`是选择生成器(生成器有很多,甚至可以直接生成一个VS项目,我们可以直接使用Visual Studio打开),这里我们需要生成Makefile,所以填写"MinGW Makefiles": - -![image-20220627221226478](https://s2.loli.net/2022/06/27/MUgkvuARteyS2wQ.png) - -可以看到已经成功在我们的构建目录中生成了: - -![image-20220627221335557](https://s2.loli.net/2022/06/27/uMtaVgILmjFhiR9.png) - -只不过它这个自动生成的Makefile写的就比较复杂了,我们也不需要去关心,接着我们像之前一样直接使用make就可以编译了: - -这里要先进入一下test目录,使用`cd test`命令修改当前工作目录: - -![image-20220627221459717](https://s2.loli.net/2022/06/27/DNnROjG1eh8V7kA.png) - -可以看到它生成的Makefile还是挺高级的,还能输出进度,现在我们的程序就构建好了,直接启动把: - -![image-20220627221546242](https://s2.loli.net/2022/06/27/IWPbV8nJ3XFLfEz.png) - -当然CLion并没有使用Makefile的编译方式,而是Ninja,并且生成的构建文件默认存放在`cmake-build-debug`中,跟make比较类似,但是速度会更快一些,不过最后都会成功构建出我们的可执行程序。 - -这下,我们就清楚整个项目中个个文件是干嘛的了。 - -### 使用LLDB调试工具 - -最后我们来说一下LLDB调试工具(与之类似的还有GDB),首先还是配置一下环境变量: - -![image-20220628002518087](https://s2.loli.net/2022/06/28/OGg9fnXztEl7Ick.png) - -LLDB调试工具用于对我们的程序进行逐步调试使用,实际上我们之前也使用调试,只不过是在IDE中的图形化界面中操作的,那么如果没有IDE呢,我们可以使用LLDB调试工具来进行调试: - -```sh -lldb .\untitled.exe -``` - -注意在编译时需要需要添加-g参数来附带调试信息,这样才可以使用gdb进行调试,否则不能(CLion默认生成的是可以调试的程序,所以直接使用就行了) - -![image-20220628002734741](https://s2.loli.net/2022/06/28/RdNOVElm5G12q8y.png) - -进入后,可以看到是这样的一个界面,我们需要输入命令来进行逐步调试,输入r就可以开始运行了: - -![image-20220628001554755](https://s2.loli.net/2022/06/28/kQodUtfj7JXZ4B5.png) - -成功运行出结果,那么具体怎么进行断点调试呢?我们可以使用`b 行号`的形式在对应的行号打上断点,比如这里对第9行进行断点: - -![image-20220628002035160](https://s2.loli.net/2022/06/28/pRuoPKOL4FYgsj8.png) - -接着我们再输入r之后,程序会暂时卡在断点位置,此时我们可以通过输入v来查看当前所有的局部变量信息: - -![image-20220628003001093](https://s2.loli.net/2022/06/28/96qVCgjebBWph5w.png) - -可以看到现在是冒泡排序的第一轮,所以`i`和`j`都还是0,并且数组是乱序的,我们输入c可以继续运行: - -![image-20220628003056092](https://s2.loli.net/2022/06/28/JND3ix4vuoEjAaG.png) - -继续运行一轮后,此时`j`就变成1了,因为内层循环执行了一次,我们可以通过p来打印变量的值: - -![image-20220628003230535](https://s2.loli.net/2022/06/28/7EvSVtXyLcJikHd.png) - -当我们不需要再调试时,可以直接结束掉程序: - -![image-20220628003631329](https://s2.loli.net/2022/06/28/z7RlaybGsLV4Bqu.png) - -当然这仅仅是展示lldb的简单使用,通过使用lldb我们就可以很轻松地在控制台进行调试了。 - -至此,包括编译、构建、调试的所有操作,我们完全可以脱离IDE纯靠命令行进行操作了(其实在没有图形化界面的年代基本上都是这样写代码的) - -*** - -## 结束语 - -到这里,我们C语言的学习就结束了,感谢各位小伙伴一直以来的支持,希望在下一期视频中,还能见到各位的身影。 - -之后我们还会开放C语言系列数据结构篇教程,敬请期待。 \ No newline at end of file diff --git a/青空笔记/C语言程序设计笔记/C语言(二).md b/青空笔记/C语言程序设计笔记/C语言(二).md deleted file mode 100644 index b6c618d..0000000 --- a/青空笔记/C语言程序设计笔记/C语言(二).md +++ /dev/null @@ -1,1902 +0,0 @@ -![](https://s2.loli.net/2022/06/17/nzpG5CLfUg927Rr.jpg) - -# C语言基础 - -前面我们已经搭建好了基本的学习环境,现在就让我们开始C语言的学习吧! - -C语言的语法层面内容相比其他语言来说,其实算少的了,但是它的难点在于很多概念上的理解,这也是为什么上一章一直在说一些计算机基础相关内容(包括这一章还会继续补一点),这样会有助于各位对于语言的理解,C语言可以说是步入编程领域的分水岭,跨过了这道坎,后续其他编程语言的学习都会无比轻松。 - -学习编程的过程可能会很枯燥,但是请各位一定不要心急,一步一个脚印,相信大家一定能通关。 - -## C程序基本格式 - -前面我们在创建项目之后自动生成了一个`.c`文件,这个就是我们编写的程序代码文件: - -```c -#include - -int main() { - printf("Hello World!"); - return 0; -} -``` - -操作系统需要执行我们的程序,但是我们的程序中可能写了很多很多的代码,那么肯定需要知道从哪里开始执行才可以,也就是程序的入口,所以我们需要提供一个入口点,我们的C语言程序入口点就是`main`函数(不过现在还没有讲到函数,所以各位就理解为固定模式即可)它的写法是: - -```c -int main() { //所有的符号一律采用英文的,别用中文 - 程序代码... -} -``` - -注意是`int`后面空格跟上`main()`,我们的程序代码使用花括号`{}`进行囊括(有的人为了方便查阅,会把前半个花括号写在下面) - -然后我们看到,如果我们需要打印一段话到控制台,那么就需要使用`printf(内容)`来完成,这其实就是一种函数调用,但是现在我们还没有接触到,我们注意到括号里面的内容就是我们要打印到控制台的内容: - -```c -printf("Hello World!"); //注意最后需要添加;来结束这一行,注意是英文的分号,不是中文的! -``` - -我们要打印的内容需要采用双引号进行囊括,被双引号囊括的这一端话,我们称为字符串,当然我们现在还没有学到,所以各位也是记固定模式就好,当我们需要向控制台打印一段话时,就要用双引号囊括这段话,然后放入`printf`即可。我们会在后续的学习中逐渐认识`printf`函数。 - -最顶上还有一句: - -```c -#include -``` - -这个是引入系统库为我们提供的函数,包括`printf`在内,所以我们以后编写一个C语言程序,就按照固定模式: - -```c -#include - -int main() { - 程序代码 -} -``` - -除了程序代码部分我们会进行编写之外,其他的地方采用固定模式就好。 - -我们在写代码的过程中可以添加一些注释文本,这些文本内容在编译时自动忽略,所以比如我们想边写边记点笔记,就可以添加注释,注释的格式为: - -```java -#include //引入标准库头文件 - -int main() { //主函数,程序的入口点 - printf("Hello World!"); //向控制台打印字符串 -} -``` - -当然我们也可以添加多行注释: - -```java -#include - -/* - * 这是由IDE自动生成的测试代码 - * 还是可以的 - */ -int main() { - printf("Hello World!"); - //最后还有一句 return 0; 但是我们可以不用写,编译器会自动添加,所以后面讲到之后我们再来说说这玩意。 -} -``` - -OK,基本的一些内容就讲解完毕了。 - -## 基本数据类型 - -我们的程序离不开数据,比如我们需要保存一个数字或是字母,这时候这些东西就是作为数据进行保存,不过不同的数据他们的类型可能不同,比如1就是一个整数,0.5就是一个小数,A就是一个字符,C语言提供了多种数据类型供我们使用,我们就可以很轻松的使用这些数据了。 - -不同的数据类型占据的空间也会不同,这里我们需要先提一个概念,就是字、字节是什么? - -我们知道,计算机底层实际上只有0和1能够表示,这时如果我们要存储一个数据,比如十进制的3,那么就需要使用2个二进制位来保存,二进制格式为`11`,占用两个位置,再比如我们要表示十进制的15,这时转换为二进制就是`1111`占用四个位置(4个bit位)来保存。一般占用8个bit位表示一个字节(B),2个字节等于1个字,所以一个字表示16个bit位,它们是计量单位。 - -我们常说的内存大小1G、2G等,实际上就是按照下面的进制进行计算的: - -8 bit = 1 B ,1024 B = 1KB,1024 KB = 1 MB,1024 MB = 1GB,1024 GB = 1TB,1024TB = 1PB(基本上是1024一个大进位,但是有些硬盘生产厂商是按照1000来计算的,所以我们买电脑的硬盘容量可能是512G的但是实际容量可能会缩水) - -在不同位数的系统下基本数据类型的大小可能会不同,因为现在主流已经是64位系统,本教程统一按照64位系统进行讲解。 - -### 原码、反码和补码 - -#### 原码 - -上面我们说了实际上所有的数字都是使用0和1这样的二进制数来进行表示的,但是这样仅仅只能保存正数,那么负数怎么办呢? - -比如现在一共有4个bit位来保存我们的数据,为了表示正负,我们可以让第一个bit位专门来保存符号,这样,我们这4个bit位能够表示的数据范围就是: - -- 最小:1111 => - (2^2+2^1+2^0) => -7 -- 最大:0111 => + (2^2+2^1+2^0) => +7 => 7 - -虽然原码表示简单,但是原码在做加减法的时候,很麻烦!以4bit位为例: - -1+(-1) = 0001 + 1001 = 怎么让计算机去计算?(虽然我们知道该去怎么算,但是计算机不知道,计算机顶多知道1+1需要进位!) - -我们得创造一种更好的表示方式!于是我们引入了反码: - -#### 反码 - -正数的反码是其本身 -负数的反码是在其原码的基础上, 符号位不变,其余各个位取反 -经过上面的定义,我们再来进行加减法: - -1+(-1) = 0001 + 1110 = 1111 => -0 (直接相加,这样就简单多了!) - -思考:1111代表-0,0000代表+0,在我们实数的范围内,0有正负之分吗? - -0既不是正数也不是负数,那么显然这样的表示依然不够合理! - -#### 补码 - -根据上面的问题,我们引入了最终的解决方案,那就是补码,定义如下: - -正数的补码就是其本身 (不变!) -负数的补码是在其原码的基础上, 符号位不变, 其余各位取反, 最后+1. (即在反码的基础上+1) -其实现在就已经能够想通了,-0其实已经被消除了!我们再来看上面的运算: - -1+(-1) = 0001 + 1111 = (1)0000 => +0 (现在无论你怎么算,也不会有-0了!) - -所以现在,4bit位能够表示的范围是:-8~+7(C使用的就是补码!) - -### 整数类型 - -我们首先来看看整数类型,整数就是不包含小数点的数据,比如`1`,`99`,`666`等数字,整数包含以下几种类型: - -* int - 占用 4 个字节,32个bit位,能够表示 -2,147,483,648 到 2,147,483,647 之间的数字,默认一般都是使用这种类型 -* long - 占用 8 个字节,64个bit位。 -* short - 占用2个字节,16个bit位。 - -### 浮点类型 - -浮点类一般用于保存小数,不过为啥不叫小数类型而是浮点类型呢?因为我们的一个小数分为整数部分和小数部分,我们需要用一部分的bit位去表示整数部分,而另一部分去表示小数部分,至于整数部分和小数部分各自占多少并不是固定的,而是浮动决定的(在计算机组成原理中会深入学习,这里就不多介绍了) - -* float - 单精度浮点,占用4个字节,32个bit位。 -* double - 双精度浮点,占用8个字节,64个bit位。 - -### 字符类型 - -除了保存数字之外,C语言还支持字符类型,我们的每一个字符都可以使用字符类型来保存: - -* char - 占用1个字节(-128~127),可以表示所有的ASCII码字符,每一个数字对应的是编码表中的一个字符: - -![image-20220603114358826](https://s2.loli.net/2022/06/17/BoaWb5EHOM7wJVy.jpg) - -编码表中包含了所有我们常见的字符,包括运算符号、数字、大小写字母等(注意只有英文相关的,没有中文和其他语言字符,包括中文的标点符号也没有) - -某些无法直接显示的字符(比如换行,换行也算一个字符)需要使用转义字符来进行表示: - -![img](https://s2.loli.net/2022/06/17/fqnzER2AS4YleTG.jpg) - -有关基本类型的具体使用我们放到下一节进行讲解。 - -*** - -## 变量 - -前面我们了解了C语言中的基本类型,那么我们如何使用呢?这时我们就可以创建不同类型的变量了。 - -### 变量的使用 - -变量就像我们在数学中学习的`x`,`y`一样,我们可以直接声明一个变量,并利用这些变量进行基本的运算,声明变量的格式为: - -```c -数据类型 变量名称 = 初始值; //其中初始值可以不用在定义变量时设定 -// = 是赋值操作,可以将等号后面的值赋值给前面的变量,等号后面可以直接写一个数字(常量)、变量名称、算式 -``` - -比如我们现在想要声明一个整数类型的变量: - -```java -int a = 10; //变量类型为int(常用),变量名称为a,变量的初始值为10 -``` - -```c -int a = 10, b = 20; //多个变量可以另起一行编写,也可以像这样用逗号隔开,注意类型必须是一样的 -``` - -其中,变量的名称并不是随便什么都可以的,它有以下规则: - -* 不能重复使用其他变量使用过的名字。 -* 只能包含英文字母或是下划线、数字,并且严格区分大小写,比如`a`和`A`不算同一个变量。 -* 虽然可以包含数字,但是不能以数字开头。 -* 不能是关键字(比如我们上面提到的所有基本数据类型,当然还有一些关键字我们会在后面认识) -* (建议)使用英文单词,不要使用拼音,多个词可以使用驼峰命名法或是通过下划线连接。 - -初始值可以是一个常量数据(比如直接写10、0.5这样的数字)也可以是其他变量,或是运算表达式的结果,这样会将其他变量的值作为初始值。 - -我们可以使用变量来做一些基本的运算: - -```java -#include - -int main() { - int a = 10; //将10作为a的值 - int b = 20; - int c = a + b; //注意变量一定要先声明再使用,这里是计算a + b的结果(算式),并作为c的初始值 -} -``` - -这里使用到了`+`运算符(之后我们还会介绍其他类型的运算符)这个运算符其实就是我们数学中学习的加法运算,会将左右两边的变量值加起来,得到结果,我们可以将运算结果作为其他变量的初始值,还是很好理解的。 - -但是现在虽然做了运算,我们还不知道运算的具体结果是什么,所以这里我们通过前面认识的`printf`函数来将结果打印到控制台: - -```c -#include - -int main() { - int a = 10; - int b = 20; - int c = a + b; - - printf(c); //直接打印变量c -} -``` - -但是我们发现这样似乎运行不出来结果,不对啊,前面你不是说把要打印到控制台的内容写到`printf`中吗,怎么这里不行呢?实际上`printf`是用于格式化打印的,我们来看看如何进行格式化打印,输出我们的变量值: - -```c -printf("c的结果是:%d", ); //使用%d来代表一个整数类型的数据(占位符),在打印时会自动将c的值替换上去 -``` - -我们来看看效果: - -![image-20220603131740600](https://s2.loli.net/2022/06/17/lb4T2HEXP7jCWIx.jpg) - -这样,我们就知道该如何打印我们变量的值了,当然,除了使用`%d`打印有符号整数之外,还有其他的: - -| 格式控制符 | 说明 | -| ------------------------------- | ------------------------------------------------------------ | -| %c | 输出一个单一的字符 | -| %hd、%d、%ld | 以十进制、有符号的形式输出 short、int、long 类型的整数 | -| %hu、%u、%lu | 以十进制、无符号的形式输出 short、int、long 类型的整数 | -| %ho、%o、%lo | 以八进制、不带前缀、无符号的形式输出 short、int、long 类型的整数 | -| %#ho、%#o、%#lo | 以八进制、带前缀、无符号的形式输出 short、int、long 类型的整数 | -| %hx、%x、%lx %hX、%X、%lX | 以十六进制、不带前缀、无符号的形式输出 short、int、long 类型的整数。如果 x 小写,那么输出的十六进制数字也小写;如果 X 大写,那么输出的十六进制数字也大写。 | -| %#hx、%#x、%#lx %#hX、%#X、%#lX | 以十六进制、带前缀、无符号的形式输出 short、int、long 类型的整数。如果 x 小写,那么输出的十六进制数字和前缀都小写;如果 X 大写,那么输出的十六进制数字和前缀都大写。 | -| %f、%lf | 以十进制的形式输出 float、double 类型的小数 | -| %e、%le %E、%lE | 以指数的形式输出 float、double 类型的小数。如果 e 小写,那么输出结果中的 e 也小写;如果 E 大写,那么输出结果中的 E 也大写。 | -| %g、%lg %G、%lG | 以十进制和指数中较短的形式输出 float、double 类型的小数,并且小数部分的最后不会添加多余的 0。如果 g 小写,那么当以指数形式输出时 e 也小写;如果 G 大写,那么当以指数形式输出时 E 也大写。 | -| %s | 输出一个字符串 | - -比如现在我们要进行小数的运算,还记得我们前面介绍的小数类型有哪些吗? - -```c -#include - -int main() { - double a = 0.5; - float b = 2.5f; //注意直接写2.5默认表示的是一个double类型的值,我们需要再后面加一个f或是F表示是flaot类型值 - - printf("a + b的结果是:%f", a + b); //根据上表得到,小数类型需要使用%f表示,这里我们可以直接将a + b放入其中 -} -``` - -可以看到,结果也是正确的: - -![image-20220603132459810](https://s2.loli.net/2022/06/17/M8ia6jKlW7epwXg.jpg) - -当然,我们也可以一次性打印多个,只需要填写多个占位符表示即可: - -```c -#include - -int main() { - double a = 0.5; - float b = 2.5f; //整数类型默认是int,如果要表示为long类型的值,也是需要在最后添加一个l或L - - printf("a = %f, b = %f", a, b); //后面可以一直添加(逗号隔开),但是注意要和前面的占位符对应 -} -``` - -结果也是正常的: - -![image-20220603132713970](https://s2.loli.net/2022/06/17/2n6GfkdlFPX4Bv1.jpg) - -我们再来看看字符类型: - -```c -char c = 'A'; //字符需要使用单引号囊括,且只能有一个字符,不能写成'AA',这就不是单个字符了 -//注意这里的A代表的是A这个字符,对应的ASCII码是65,实际上c存储的是65这个数字 -``` - -我们也可以通过格式化打印来查看它的值: - -```c -#include - -int main() { - char c = 'A'; - printf("变量c的值为:%c 对应的ASCII码为:%d", c, c); //这里我们使用%c来以字符形式输出,%d输出的是变量数据的整数形式,其实就是对应的ASCII码 -} -``` - -![image-20220603133727498](https://s2.loli.net/2022/06/17/VsMorWTd13YLp8Q.jpg) - -当然,我们也可以直接让char存储一个数字(ASCII码),同样也可以打印出对应的字符: - -```c -#include - -int main() { - char c = 66; - printf("变量c的值为:%c 对应的ASCII码为:%d", c, c); -} -``` - -![image-20220603133858133](https://s2.loli.net/2022/06/17/shjrQKayID9YwVc.jpg) - -那么现在请各位小伙伴看看下面这段代码会输出什么: - -```c -#include - -int main() { - int a = 10; - char c = 'a'; - printf("变量c的ASCII码为:%d", c); -} -``` - -没错,这里得到的结果就是字符`a`的ASCII码值,注意千万不要认为c得到的是变量a的值,这里使用的是字符`a`,跟上面的变量a半毛钱关系都没有: - -![image-20220603134234040](https://s2.loli.net/2022/06/17/X1f6SzW7aBoFnJh.jpg) - -但是如果我们去掉引号,就相当于把变量a的值给了c,c现在的ASCII码就是10了,所以这里一定要分清楚。 - -对于某些无法表示的字符,比如换行这类字符,我们没办法直接敲出来,只能使用转义字符进行表示: - -```c -char c = '\n'; -``` - -详细的转义字符表参见前面的基本数据类型章节。 - -变量除了有初始值之外,也可以在后续的过程中得到新的值: - -```c -#include - -int main() { - short s = 10; - s = 20; //重新赋值为20,注意这里就不要再指定类型了,指定类型只有在声明变量时才需要 - printf("%d", s); //打印结果 -} -``` - -可以看到,得到的是我们最后一次对变量修改的结果: - -![image-20220603135152184](https://s2.loli.net/2022/06/17/mIXbe91qu7B5ZKU.jpg) - -那要是我们不对变量设定初始值呢?那么变量会不会有默认值: - -```c -#include - -int main() { - int a, b, c, d; - printf("%d,%d,%d,%d", a, b, c, d); -} -``` - -可以看到,虽然定义变量但是我们没有为其设定初始值,那么它的值就是不确定的了(千万注意并不是不设定值默认就是0): - -![image-20220603141341554](https://tva1.sinaimg.cn/large/e6c9d24egy1h2v11c4z44j212o02mmxa.jpg) - -所以各位小伙伴以后在使用时一定要注意这个问题,至于为什么不是0,这是因为内存分配机制,我们在下一章高级篇再进行讲解。 - -我们再来看一个例子: - -```c -#include - -int main() { - char c = 127; //已经到达c的最大值了 - c = c + 1; //我不管,我就要再加 - printf("%d", c); //这时会得到什么结果? -} -``` - -![image-20220603143909688](https://s2.loli.net/2022/06/17/N7lGU4n2OkxjLcF.jpg) - -怎么127加上1还变成-128了呢?这是由于位数不够,导致运算结果值溢出: - -* 127 + 1= 01111111 + 1 -* 由于现在是二进制,满2进1,所以最后变成 -* 10000000 = 补码形式的 -128 - -所以,了解上面这些计算机底层原理是很重要的,我们能够很轻松地知道为什么会这样。 - -在我们的运算中,可能也会存在一些一成不变的值,比如`π`的值永远都是`3.1415....`,在我们的程序中,也可以使用这样不可变的变量,我们成为常量。 - -定义常量和变量比较类似,但是需要在前面添加一个`const`关键字,表示这是一个常量: - -![image-20220603140454728](https://tva1.sinaimg.cn/large/e6c9d24egy1h2v0s7fdvgj2128052gls.jpg) - -可以看到,常量在一开始设定初始值后,后续是不允许进行修改的。 - -### 无符号数 - -我们知道,所有的数据底层都是采用二进制来进行保存的,而第一位则是用于保存符号位,但是如果我们不考虑这个符号位,那么所有的数都是按照正数来表示,比如考虑了符号位的`char`类型: - -* 考虑符号表示范围:-128~127 -* 不考虑符号:0~255 - -我们也可以直接使用这些不带符号位的数据类型: - -```c -int main() { - unsigned char c = -65; //数据类型前面添加unsigned关键字表示采用无符号形式 - printf("%u", c); //%u以无符号形式输出十进制数据 -} -``` - -可以看到这里给了无符号char类型c一个-65的值,但是现在很明显符号位也是作为数值的表示部分,所以结果肯定不是-65: - -![image-20220603142210120](https://s2.loli.net/2022/06/17/Hkg4MIFcXzKwClp.jpg) - -结合我们前面学习的基础知识,我们来看看为什么得到的是191这个数字。首先char类型占据一个字节,8个bit位: - -* 00000000 -> 现在赋值-65 -> -65的补码形式 -> 10111111 -* 由于现在没有符号位,一律都是正数,所以,10111111 = 128 + 32 + 16 + 8 + 4 + 2 + 1 = 191 - -我们也可以直接以无符号数形式打印: - -```c -#include - -int main() { - int i = -1; - printf("%u", i); //%u以无符号形式输出十进制数据 -} -``` - -![image-20220603143441616](https://s2.loli.net/2022/06/17/PYNZLCWc6OFVui8.jpg) - -得到无符号int的最大值。 - -### 类型转换 - -一种类型的数据可以转换为其他类型的数据,这种操作我们称为类型转换,类型转换分为**自动类型转换**和**强制类型转换**,比如我们现在希望将一个short类型的数据转换为int类型的数据: - -```java -#include - -int main() { - short s = 10; - int i = s; //直接将s的值传递给i即可,但是注意此时s和i的类型不同 -} -``` - -这里其实就是一种自动类型转换,自动类型转换就是编译器隐式地进行的数据类型转换,这种转换不需要我们做什么,我们直接写就行,会自动进行转换操作。 - -```c -float a = 3; //包括这里我们给的明明是一个int整数3但是却可以赋值给float类型,说明也是进行了自动类型转换 -``` - -如果我们使用一个比转换的类型最大值都还要大的值进行类型转换,比如: - -```c -#include - -int main() { - int a = 511; - char b = a; //最大127 - printf("%d", b); -} -``` - -![image-20220606180919318](https://s2.loli.net/2022/06/17/U9rxV53lCzB4ARm.jpg) - -很明显char类型是无法容纳大于127的数据的,因为只占一个字节,而int占4个字节,如果需要进行转换,那么就只能丢掉前面的就只保留char所需要的那几位了,所以这里得到的就是-1: - -* 511 = int -> 00000000 00000000 00000001 11111111 -* char -> 11111111 -> -1 - -我们也可以将整数和小数类型的数据进行互相转换: - -```c -#include - -int main() { - int a = 99; - double d = a; - printf("%f", d); -} -``` - -![image-20220606180529600](https://tva1.sinaimg.cn/large/e6c9d24egy1h2yolhtg3rj215o02iq2z.jpg) - -不过这里需要注意的是,小数类型在转换回整数类型时,会丢失小数部分(注意,不是四舍五入,是直接丢失小数!): - -```c -#include - -int main() { - double a = 3.14; - int b = a; //这里编译器还提示了黄标,我们可以通过之后讲到的强制类型转换来处理 - printf("%d", b); -} -``` - -![image-20220606180719070](https://tva1.sinaimg.cn/large/e6c9d24egy1h2yoncxo3gj210o02kmx5.jpg) - -除了赋值操作可以进行自动类型转换之外,在运算中也会进行自动类型转换,比如: - -```c -#include - -int main() { - float a = 2; - int b = 3; - double c = b / a; // "/" 是除以的意思,也就是我们数学中的除法运算,这里表示a除以b - printf("%f", c); -} -``` - -![image-20220606191838425](https://s2.loli.net/2022/06/17/FQpoer6ANgjsIlY.jpg) - -可以看到,这里得到的结果是小数1.5,但是参与运算的既有整数类型,又有浮点类型,结果为什么就确定为浮点类型了呢?这显然是由于类型转换导致的。那么规则是什么呢? - -![image-20220606191412418](https://s2.loli.net/2022/06/17/39b2LouK8dfnNhv.jpg) - -* 不同的类型优先级不同(根据长度而定) -* char和short类型在参与运算时一律转换为int再进行运算。 -* 浮点类型默认按双精度进行计算,所以就算有float类型,也会转换为double类型参与计算。 -* 当有一个更高优先级的类型和一个低优先级的类型同时参与运算时,统一转换为高优先级运算,比如int和long参与运算,那么int转换为long再算,所以结果也是long类型,int和double参与运算,那么先把int转换为double再算。 - -我们接着来看看强制类型转换,我们可以为手动去指定类型,强制类型转换格式如下: - -```c -(强制转换类型) 变量、常量或表达式; -``` - -比如: - -```c -#include - -int main() { - int a = (int) 2.5; //2.5是一个double类型的值,但是我们可以强制转换为int类型赋值给a,强制转换之后小数部分丢失 - printf("%d", a); -} -``` - -我们也可以对一个算式的结果进行类型转换: - -```c -#include - -int main() { - double a = 3.14; - int b = (int) (a + 2.8); //注意得括起来表示对整个算式的结果进行类型转换(括号跟数学中的挺像,也是提升优先级使用的,我们会在运算符部分详细讲解),不然强制类型转换只对其之后紧跟着的变量生效 - printf("%d", b); -} -``` - -在我们需要得到两个int相除之后带小数的结果时,强制类型转换就显得很有用: - -```java -#include - -int main() { - int a = 10, b = 4; - double c = a / b; //不进行任何的类型转换,int除以int结果仍然是int,导致小数丢失 - double d = (double) a / b; //对a进行强制类型转换,现在是double和int计算,根据上面自动类型转换规则,后面的int自动转换为double,结果也是double了,这样就是正确的结果了 - printf("不进行类型转换: %f, 进行类型转换: %f", c, d); -} -``` - -合理地使用强制类型转换,能够解决我们很多情况下的计算问题。 - -*** - -## 运算符 - -前面我们了解了如何声明变量以及变量的类型转换,那么我们如何去使用这些变量来参与计算呢?这是我们本小节的重点。 - -### 基本运算符 - -基本运算符包含我们在数学中常用的一些操作,比如加减乘除,分别对应: - -* 加法运算符:+ -* 减法运算符:- -* 乘法运算符:* -* 除法运算符:/(注意不是“\”,看清楚一点) - -当然,还有我们之前使用的赋值运算符`=`,我们先来看看赋值运算符的使用,其实在之前我们已经学习过了: - -```c -变量 = 值 //其中,值可以直接是一个数字、一个变量、表达式的结果等 -``` - -实际上等号左边的内容准确的说应该是一个左值,不过大部分情况下都是变量,这里就不展开左值和右值的话题了(感兴趣的小伙伴可以去详细了解,有助于后面学习C++理解右值引用) - -最简单的用法就是我们前面所说的,对一个变量进行赋值操作: - -```c -int a = 10; -``` - -也可以连续地使用赋值操作,让一连串的变量都等于后面的值: - -```c -int a, b; -a = b = 20; //从右往左依次给b和a赋值20 -``` - -可以看出,实际上`=`运算除了赋值之外,和加减乘除运算一样也是有结果的,比如上面的 a = 就是b = 20 运算的结果(可以看着一个整体),只不过运算的结果就是b被赋值的值,也就是20。 - -我们接着来看加减法,这个就和我们数学中的是一样的了: - -```c -#include - -int main() { - int a = 10, b = 5; - printf("%d", a + b); //打印 a + b 的结果 -} -``` - -当然也可以像数学中那样写在一个数或是变量的最前面,表示是正数: - -```c -int a = +10, b = +5; -``` - -不过默认情况下就是正数,所以没必要去写一个+号。减法运算符其实也是一样的: - -```c -#include - -int main() { - int a = 10, b = 5; - printf("%d", a - b); //打印 a - b 的结果 -} -``` - -```c -#include - -int main() { - int a = -10; //等于 -10 - printf("%d", -a); //输出 -a 的值,就反着来嘛 -} -``` - -接着我们来看看乘法和除法运算: - -```c -#include - -int main() { - int a = 20, b = 10; - printf("%d, %d", a * b, a / b); //使用方式和上面的加减法是差不多的 -} -``` - -还有一个比较有意思的取模运算: - -```c -#include - -int main() { - int a = 20, b = 8; - printf("%d", a % b); //取模运算实际上就是计算a除以b的余数 -} -``` - -不过很遗憾,在C中没有指数相关的运算符(比如要计算5的10次方),在后面学习了循环语句之后,我们可以尝试来自己实现一个指数运算。 - -### 运算符优先级 - -和数学中一样,运算符是有优先级的: - -```java -#include - -int main() { - int a = 20, b = 10; - printf("%d", a + a * b); //如果没有优先级,那么结果应该是400 -} -``` - -很明显这里的结果是考虑了优先级的: - -![image-20220606205103154](https://tva1.sinaimg.cn/large/e6c9d24egy1h2ytdqy91yj211o02wq2u.jpg) - -在数学中,加减运算的优先级是没有乘除运算优先级高的,所以我们需要先计算那些乘除法,最后再来进行加减法的计算,而C语言中也是这样,运算符之间存在优先级概念。我们在数学中,如果需要优先计算加减法再计算乘除法,那么就需要使用括号来提升加减法的优先级,C语言也可以: - -```c -#include - -int main() { - int a = 20, b = 10; - printf("%d", (a + a) * b); //优先计算 a + a 的结果,再乘以 b -} -``` - -那要是遇到多重的呢?类似于下面的这种: - -``` -数学上的写法:[1 - (3 + 4)] x (-2 ÷ 1) = ? -``` - -那么我们在C中就可以这样编写: - -```java -#include - -int main() { - printf("%d", (1 - (3 + 4)) * (-2 / 1)); //其实写法基本差不多,只需要一律使用小括号即可 -} -``` - -这样,我们就可以通过`()`运算符,来提升运算优先级了。 - -我们来总结一下,上面运算符优先级如下,从左往右依次递减: - -* `()` > `+ - (做符号表示,比如-9)` > `* / %` > `+ - (做加减运算)` > `=` - -根据上面的优先级,我们来看看下面`a`的结果是什么: - -```c -int c; -int a = (3 + (c = 2)) * 6; -``` - -```c -int b, c; -int a = (b = 5, c = b + 8); //逗号运算符从前往后依次执行,赋值结果是最后边的结果 -``` - -### 自增自减运算符 - -我们可以快速使用自增运算符来将变量的值`+1`,正常情况下我们想要让一个变量值自增需要: - -```c -int a = 10; -a = a + 1; -``` - -现在我们只需要替换为: - -```c -int a = 10; -++a; //使用自增运算符,效果等价于 a = a + 1 -``` - -并且它也是有结果的,除了做自增运算之外,它的结果是自增之后的值: - -```c -#include - -int main() { - int a = 10; - //int b = a = a + 1; 下面效果完全一致 - int b = ++a; - printf("%d", b); -} -``` - -当然我们也可以将自增运算符写到后面,和写在前面的区别是,它是先返回当前变量的结果,再进行自增的,顺序是完全相反的: - -```c -#include - -int main() { - int a = 10; - int b = a++; //写在后面和写在前面是有区别的 - printf("a = %d, b = %d", a, b); -} -``` - -> 重点内容:自增运算符`++`在前,那么先自增再出结果;自增运算符`++`在后,那么先出结果再自增。各位小伙伴可以直接记运算符的位置,来方便记忆。 - -那要是现在我们不想自增1而是自增2或是其他的数字呢?我们可以使用复合赋值运算符,正常情况下依然是使用普通的赋值运算符: - -```c -int a = 10; -a = a + 5; -``` - -但是现在我们可以简写: - -```c -int a = 10; -a += 5; -``` - -效果和上面是完全一样的,并且得到的结果也是在自增之后的: - -```c -#include - -int main() { - int a = 10; - int b = a += 5; - printf("a = %d", b); -} -``` - -复合赋值运算符不仅仅支持加法,还支持各种各样的运算: - -```c -#include - -int main() { - int a = 10; - a %= 3; //可以复合各种运算,比如加减乘除、模运算、包括我们我们还要讲到的位运算等 - printf("a = %d", a); -} -``` - -当然,除了自增操作之外,还有自减操作: - -```c -#include - -int main() { - int a = 10; - a--; //--是自减操作,相当于a = a - 1,也可以在前后写,规则和上面的自增是一样的 - printf("a = %d", a); -} -``` - -注意自增自减运算符和`+`、`-`做符号是的优先级一样,仅次于`()`运算符,所以在编写时一定要注意: - -```c -#include - -int main() { - int a = 10; - int b = 5 * --a; - printf("b = %d", b); -} -``` - -### 位运算符 - -前面我们学习了乘法运算符`*`,当我们想要让一个变量的值变成2倍,只需要做一次乘法运算即可: - -```c -int a = 10; -a *= 2; //很明显算完之后a就是20了 -``` - -但是我们现在可以利用位运算来快速进行计算: - -```c -int a = 10; -a = a << 1; //也可以写成复合形式 a <<= 1 -``` - -我们会发现这样运算之后得到的结果居然也是20,这是咋算出来的呢?实际上`<<`是让所有的bit位进行左移操作,上面就是左移1位,我们可以来看看: - -* 10 = 00001010 现在所以bit位上的数据左移一位 00010100 = 20 - -是不是感觉特别神奇?就像我们在十进制中,做乘以10的操作一样:22乘以10那么就直接左移了一位变成220,而二进制也是一样的,如果让这些二进制数据左移的话,那么相当于在进行乘2的操作。 - -比如: - -```c -#include - -int main() { - int a = 6; - a = a << 2; //让a左移2位,实际上就是 a * 2 * 2,a * 2的平方(类比十进制,其实还是很好理解的) - printf("a = %d", a); -} -``` - -当然能左移那肯定也可以右移: - -```c -#include - -int main() { - int a = 6; - a = a >> 1; //右移其实就是除以2的操作 - printf("a = %d", a); -} -``` - -当然除了移动操作之外,我们也可以进行按位比较操作,先来看看按位与操作: - -```c -#include - -int main() { - int a = 6, b = 4; - int c = a & b; //按位与操作 - printf("c = %d", c); -} -``` - -按位与实际上也是根据每个bit位来进行计算的: - -* 4 = 00000100 -* 6 = 00000110 -* 按位与实际上就是让两个数的每一位都进行比较,如果两个数对应的bit位都是1,那么结果的对应bit位上就是1,其他情况一律为0 -* 所以计算结果为:00000100 = 4 - -除了按位与之外,还有按位或运算: - -```c -int a = 6, b = 4; -int c = a | b; -``` - -* 4 = 00000100 -* 6 = 00000110 -* 按位与实际上也是让两个数的每一位都进行比较,如果两个数对应bit位上其中一个是1,那么结果的对应bit位上就是1,其他情况为0。 -* 所以计算结果为:00000110 = 6 - -还有异或和按位非(按位否定): - -```c -int a = 6, b = 4; -int c = a ^ b; //注意^不是指数运算,表示按位异或运算,让两个数的每一位都进行比较,如果两个数对应bit位上不同时为1或是同时为0,那么结果就是1,否则结果就是0,所以这里的结果就是2 -a = ~a; //按位否定针对某个数进行操作,它会将这个数的每一个bit位都置反,0变成1,1变成0,猜猜会变成几 -``` - -按位运算都是操作数据底层的二进制位来进行的。 - -### 逻辑运算符 - -最后我们来看一下逻辑运算符,逻辑运算符主要用到下一节的流程控制语句中。 - -逻辑运算符用于计算真和假,比如今天要么下雨要么不下雨,现在我们想要在程序中判断一下是否下雨了,这时就需要用到逻辑运算符,我们来举个例子: - -```c -#include - -int main() { - int a = 10; - _Bool c = a < 0; //我们现在想要判断a的值是否小于0,我们可以直接使用小于符号进行判断,最后得到的结果只能是1或0 - //虽然结果是一个整数,但是这里推荐使用_Bool类型进行接收,它只能表示0和1(更加专业一些) - printf("c = %d", c); -} -``` - -实际上在C语言中,0一般都表示为假,而非0的所有值(包括正数和负数)都表示为真,上面得到1表示真,0表示假。 - -除了小于符号可以判断大小之外,还有:`<`、` <=`、`>=`、`>` - -比如我们现在想要判断字符C是否为大写字母: - -```c -#include - -int main() { - char c = 'D'; - printf("c是否为大写字母:%d", c >= 'A'); //由于底层存储的就是ASCII码,这里可以比较ASCII码,也可以写成字符的形式 -} -``` - -但是我们发现,现在我们的判断只能判断一个条件,也就是说只能判断c是否是大于等于'A'的,但是不能同时判断c的值是否是小于等于'Z'的,所以这时,我们就需要利用逻辑与和逻辑或来连接两个条件了: - -```c -#include - -int main() { - char c = 'D'; - printf("c是否为大写字母:%d", c >= 'A' && c <= 'Z'); //使用&&表示逻辑与,逻辑与要求两边都是真,结果才是真 -} -``` - -又比如现在我们希望判断c是否不是大写字母: - -```c -#include - -int main() { - char c = 'D'; - printf("c是否不为大写字母:%d", c < 'A' || c > 'Z'); //使用||表示逻辑或,只要两边其中一个为真或是都为真,结果就是真 -} -``` - -当然我们也可以判断c是否为某个字母: - -```c -#include - -int main() { - char c = 'D'; - printf("c是否为字母A:%d", c == 'A'); //注意判断相等时使用==双等号 -} -``` - -判断不相等也可以使用: - -```c -printf("c是否不为字母A:%d", c != 'A'); -``` - -我们也可以对某个结果取反: - -```c -#include - -int main() { - int i = 20; - printf("i是否不小于20:%d", !(i < 20)); //使用!来对结果取反,注意!优先级很高,一定要括起来,不然会直接对i生效 -} -``` - -这里要注意一下`!`如果直接作用于某个变量或是常量,那么会直接按照上面的规则(0表示假,非0表示真)非0一律转换为0,0一律转换为1。 - -这里我们可以结合三目运算符来使用这些逻辑运算符: - -```c -#include - -int main() { - int i = 0; - char c = i > 10 ? 'A' : 'B'; //三目运算符格式为:expression ? 值1 : 值2,返回的结果会根据前面判断的结果来的 - //这里是判断i是否大于10,如果大于那么c的值就是A,否则就是B - printf("%d", c); -} -``` - -最后,我们来总结一下前面认识的所有运算符的优先级,从上往下依次降低: - -| 运算符 | 解释 | 结合方式 | -| ------------------------------- | ------------------------------------ | -------- | -| () | 同数学中的括号,直接提升到最高优先级 | 由左向右 | -| ! ~ ++ -- + - | 否定,按位否定,增量,减量,正负号 | 由右向左 | -| * / % | 乘,除,取模 | 由左向右 | -| + - | 加,减 | 由左向右 | -| << >> | 左移,右移 | 由左向右 | -| < <= >= > | 小于,小于等于,大于等于,大于 | 由左向右 | -| == != | 等于,不等于 | 由左向右 | -| & | 按位与 | 由左向右 | -| ^ | 按位异或 | 由左向右 | -| \| | 按位或 | 由左向右 | -| && | 逻辑与 | 由左向右 | -| \|\| | 逻辑或 | 由左向右 | -| ? : | 条件 | 由右向左 | -| = += -= *= /= &= ^= \|= <<= >>= | 各种赋值 | 由右向左 | -| , | 逗号(顺序) | 由左向右 | - -*** - -## 流程控制 - -前面我们学习了运算符,知道该如何使用运算符来计算我们想要的内容,但是仅仅依靠计算我们的程序还没办法实现丰富多样的功能,我们还得加点额外的控制操作。 - -### 分支语句 - if - -我们可能会有这样的一个需求,就是判断某个条件,当满足此条件时,才执行某些代码,那这个时候该怎么办呢?我们可以使用`if`语句来实现: - -```c -#include - -int main() { - int i = 0; - if(i > 20) { //我们只希望i大于20的时候才执行下面的打印语句 - printf("Hello World!"); - } - printf("Hello World?"); //后面的代码在if之外,无论是否满足if条件,都跟后面的代码无关,所以这里的代码任何情况下都会执行 -} -``` - -if语句的标准格式如下: - -```c -if(判断条件) { - 执行的代码 -} -``` - -当然如果只需要执行一行代码的话,可以省略花括号: - -```c -if(判断条件) - 一行执行的代码 //注意这样只有后一行代码生效,其他的算作if之外的代码了 -``` - -现在我们需求升级了,我们需要判断某个条件,当满足此条件时,执行某些代码,而不满足时,我们想要执行另一段代码,我们就可以结合else语句来实现: - -```c -#include - -int main() { - int i = 0; - if(i > 20) { - printf("Hello World!"); //满足if条件才执行 - } else { - printf("LBWNB"); //不满足if条件才执行 - } -} -``` - -但是这样可能还是不够用,比如我们现在希望判断学生的成绩,不同分数段打印的等级不一样,比如90以上就是优秀,70以上就是良好,60以上是及格,其他的都是不及格,那么这种我们又该如何判断呢?要像这样进行连续判断,我们需要使用`else-if`来完成: - -```c -#include - -int main() { - int score = 2; - if(score >= 90) { - printf("优秀"); - } else if (score >= 70) { - printf("良好"); - } else if (score >= 60){ - printf("及格"); - } else{ - printf("不及格"); - } -} -``` - -`if`这类的语句(包括我们下面还要介绍的三种)都是支持嵌套使用的,比如我们现在希望低于60分的同学需要补习,0-30分需要补Java,30-60分需要补C++,这时我们就需要用到嵌套: - -```c -#include - -int main() { - int score = 2; - if(score < 60) { //先判断不及格 - if(score > 30) { //在内层再嵌套一个if语句进行进一步的判断 - printf("学习C++"); - } else{ - printf("学习Java"); - } - } -} -``` - -### 分支语句 - switch - -前面我们介绍了if语句,我们可以通过一个if语句轻松地进行条件判断,然后根据对应的条件,来执行不同的逻辑,当然除了这种方式之外,我们也可以使用switch语句来实现,它更适用于多分支的情况: - -```c -switch (目标) { //我们需要传入一个目标,比如变量,或是计算表达式等 - case 匹配值: //如果目标的值等于我们这里给定的匹配值,那么就执行case后面的代码 - 代码... - break; //代码执行结束后需要使用break来结束,否则会继续溜到下一个case继续执行代码 -} -``` - -比如现在我们要根据学生的等级进行分班,学生有ABC三个等级: - -```c -#include - -int main() { - char c = 'A'; - switch (c) { //这里目标就是变量c - case 'A': //分别指定ABC三个匹配值,并且执行不同的代码 - printf("去尖子班!准备冲刺985大学!"); - break; //执行完之后一定记得break,否则会继续向下执行下一个case中的代码 - case 'B': - printf("去平行班!准备冲刺一本!"); - break; - case 'C': - printf("去职高深造。"); - break; - } -} -``` - -switch可以精准匹配某个值,但是它不能进行范围判断,比如我们要判断分数段,这时用switch就很鸡肋了。 - -当然除了精准匹配之外,其他的情况我们可以用default来表示: - -```c -switch (目标) { - case: ... - default: - 其他情况下执行的代码 -} -``` - -比如: - -```c -#include - -int main() { - char c = 'A'; - switch (c) { - case 'A': - printf("去尖子班!"); - break; - case 'B': - printf("去平行班!"); - break; - case 'C': - printf("去差生班!"); - break; - default: //其他情况一律就是下面的代码了 - printf("去读职高,分流"); - } -} -``` - -当然switch中可以继续嵌套其他的流程控制语句,比如if: - -```c -#include - -int main() { - char c = 'A'; - switch (c) { - case 'A': - if(c == 'A') { //嵌套一个if语句 - printf("去尖子班!"); - } - break; - case 'B': - printf("去平行班!"); - break; - } -} -``` - -### 循环语句 - for - -通过前面的学习,我们了解了如何使用分支语句来根据不同的条件执行不同的代码,我们接着来看第二种重要的流程控制语句,循环语句。 - -我们在某些时候,可能需要批量执行某些代码: - -```c -#include - -int main() { - printf("伞兵一号卢本伟准备就绪!"); //把这句话给我打印三遍 - printf("伞兵一号卢本伟准备就绪!"); - printf("伞兵一号卢本伟准备就绪!"); -} -``` - -遇到这种情况,我们由于还没学习循环语句,那么就只能写N次来实现这样的多次执行。现在我们可以使用for循环语句来多次执行: - -```c -for (表达式1表达式2;表达式3) { - 循环体 -} -``` - -我们来介绍一下: - -* 表达式1:在循环开始时仅执行一次。 -* 表达式2:每次循环开始前会执行一次,要求为判断语句,用于判断是否可以结束循环,若结果为真,那么继续循环,否则结束循环。 -* 表达式3:每次循环完成后会执行一次。 -* 循环体:每次循环都会执行循环体里面的内容,直到循环结束。 - -一个标准的for循环语句写法如下: - -```c -//比如现在我们希望循环4次 -for (int i = 0; i < 4; ++i) { - //首先定义一个变量i用于控制循环结束 - //表达式2在循环开始之前判断是否小于4 - //表达式3每次循环结束都让i自增一次,这样当自增4次之后不再满足条件,循环就会结束,正好4次循环 -} -``` - -我们来看看按顺序打印的结果: - -```c -#include - -int main() { - //比如现在我们希望循环4次 - for (int i = 0; i < 4; ++i) { - printf("%d, ", i); - } -} -``` - -![image-20220611152257585](https://tva1.sinaimg.cn/large/e6c9d24egy1h34bzvoslpj2136030aa3.jpg) - -这样,利用循环我们就可以批量执行各种操作了。 - -注意,如果表达式2我们什么都不写,那么会默认判定为真: - -```c -#include - -int main() { - for (int i = 0; ; ++i) { //表达式2不编写任何内容,默认为真,这样的话循环永远都不会结束 - printf("%d, ", i); - } -} -``` - -![image-20220612164349847](https://s2.loli.net/2022/06/17/AHoTpOIEYu8Jc7R.jpg) - -所以,如果我们想要编写一个无限循环,其实什么都不用写就行了: - -```c -#include - -int main() { - for (;;) { //什么都不写直接无限循环,但是注意,两个分号还是要写的 - printf("Hello World!\n"); //这里用到了\n表示换行 - } -} -``` - -当然,我们也可以在循环过程中提前终止或是加速循环的进行,这里我们需要认识两个新的关键字: - -```c -for (int i = 0; i < 10; ++i) { - if(i == 5) break; //比如现在我们希望在满足某个条件下提前终止循环,可以使用break关键字来跳出循环 - printf("%d", i); -} -``` - -![image-20220613101128788](https://s2.loli.net/2022/06/17/vW37gTywX92AI1r.jpg) - -可以看到,当满足条件时,会直接通过break跳出循环,循环不再继续下去,直接结束掉。 - -我们也可以加速循环: - -```c -for (int i = 0; i < 10; ++i) { - if(i == 5) continue; //使用continue关键字会加速循环,无论后面有没有未执行完的代码,都会直接开启下一轮循环 - printf("%d", i); -} -``` - -![image-20220613101847762](https://s2.loli.net/2022/06/17/WeHJkqmEfGcn53r.jpg) - -虽然使用break和continue关键字能够更方便的控制循环,但是注意在多重循环嵌套下,它只对离它最近的循环生效(就近原则): - -```c -for (int i = 1; i < 4; ++i) { - for (int j = 1; j < 4; ++j) { - if(i == j) continue; //当i == j时加速循环 - printf("%d, %d\n", i, j); - } -} -``` - -![image-20220613102100374](https://s2.loli.net/2022/06/17/Kh9U3l7rEqtGuQ1.jpg) - -可以看到,continue仅仅加速的是内层循环,而对外层循环没有任何效果,同样的,break也只会终结离它最近的: - -```c -for (int i = 1; i < 4; ++i) { - for (int j = 1; j < 4; ++j) { - if(i == j) break; //当i == j时终止循环 - printf("%d, %d\n", i, j); - } -} -``` - -![image-20220613102347086](https://s2.loli.net/2022/06/17/6TaNChfX9tI351r.jpg) - -### 循环语句 - while - -前面我们介绍了for循环语句,我们接着来看第二种while循环,for循环要求我们填写三个表达式,而while相当于是一个简化版本,它只需要我们填写循环的维持条件即可,比如: - -```c -#include - -int main() { - while (1) { //每次循环开始之前都会判断括号内的内容是否为真,如果是就继续循环 - printf("Hello World!\n"); //这里会无限循环 - } -} -``` - -相比for循环,while循环更多的用在不明确具体的结束时机的情况下,而for循环更多用于明确知道循环的情况,比如我们现在明确要进行循环10次,此时用for循环会更加合适一些,又比如我们现在只知道当`i`大于10时需要结束循环,但是`i`在循环多少次之后才不满足循环条件我们并不知道,此时使用while就比较合适了。 - -```c -#include - -int main() { - int i = 100; //比如现在我们想看看i不断除以2得到的结果会是什么,但是循环次数我们并不明确 - while (i > 0) { //现在唯一知道的是循环条件,只要大于0那么就可以继续除 - printf("%d, ", i); - i /= 2; //每次循环都除以2 - } -} -``` - -![image-20220612170911315](https://s2.loli.net/2022/06/17/xBE3NKZipVqJwjP.jpg) - -while也支持使用break和continue来进行循环的控制: - -```c -int i = 100; -while (i > 0) { - if(i < 30) break; - printf("%d, ", i); - i /= 2; -} -``` - -![image-20220613102935994](https://tva1.sinaimg.cn/large/e6c9d24egy1h36er98fx8j213002u0sy.jpg) - -我们可以反转循环判断的位置,可以先执行循环内容,然后再做循环条件判断,这里要用到`do-while`语句: - -```c -#include - -int main() { - do { //无论满不满足循环条件,先执行循环体里面的内容 - printf("Hello World!"); - } while (0); //再做判断,如果判断成功,开启下一轮循环,否则结束 -} -``` - -![image-20220613103504978](https://s2.loli.net/2022/06/17/clJF7jrBqWAtbZ4.jpg) - -### 实战:寻找水仙花数 - -> “水仙花数(Narcissistic number)也被称为超完全数字不变数(pluperfect digital invariant, PPDI)、自恋数、自幂数、阿姆斯壮数或阿姆斯特朗数(Armstrong number),水仙花数是指**一个 3 位数,它的每个位上的数字的 3次幂之和等于它本身。**例如:1^3 + 5^3+ 3^3 = 153。” - -现在请你设计一个C语言程序,打印出所有1000以内的水仙花数。 - -### 实战:打印九九乘法表 - -![image-20220613105029975](https://tva1.sinaimg.cn/large/e6c9d24egy1h36fd08zh4j21dk0bmq4j.jpg) - -现在我们要做的是在我们的程序中,也打印出这样的一个乘法表出来,请你设计一个C语言程序来实现它。 - -![image-20220613105519595](https://s2.loli.net/2022/06/17/BW7lvFXy4GRb1P5.jpg) - -### 实战:斐波那契数列解法其一 - -> 斐波那契数列(Fibonacci sequence),又称[黄金分割](https://baike.baidu.com/item/黄金分割/115896)数列,因数学家莱昂纳多·斐波那契(Leonardo Fibonacci)以兔子繁殖为例子而引入,故又称为“兔子数列”,指的是这样一个数列:**1、1、2、3、5、8、13、21、34、……**在数学上,斐波那契数列以如下被以递推的方法定义:*F*(0)=0,*F*(1)=1, *F*(n)=*F*(n - 1)+*F*(n - 2)(*n* ≥ 2,*n* ∈ N*)在现代物理、准[晶体结构](https://baike.baidu.com/item/晶体结构/10401467)、化学等领域,斐波纳契数列都有直接的应用,为此,美国数学会从 1963 年起出版了以《斐波纳契数列季刊》为名的一份数学杂志,用于专门刊载这方面的研究成果。 - -斐波那契数列:1,1,2,3,5,8,13,21,34,55,89...,不难发现一个规律,实际上从第三个数开始,每个数字的值都是前两个数字的和,现在请你设计一个C语言程序,可以获取斐波那契数列上任意一位的数字,比如获取第5个数,那么就是5。 - -```c -#include - -int main() { - int target = 7, result; //target是要获取的数,result是结果 - - //请在这里实现算法 - - printf("%d", result); -} -``` - -*** - -## 数组 - -现在我们有一个新的需求,我们需要存储2022年每个月都天数,那么此时,为了保存这12个月的天数,我们就得创建12个变量: - -```c -#include - -int main() { - int january = 31, february = 28, march = 31 ... -} -``` - -这样是不是太累了点?万一我们想保存100个商品的售价,那岂不是得创建100个变量?这肯定不行啊。 - -### 数组的创建和使用 - -为了解决这种问题,我们可以使用数组,什么是数组呢?简单来说,就是存放数据的一个组,所有的数据都统一存放在这一个组中,一个数组可以同时存放多个数据。比如现在我们想保存12个月的天数,那么我们只需要创建一个int类型的数组就可以了,它可以保存很多个int类型的数据,这些保存在数组中的数据,称为“元素”: - -```c -int arr[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; //12个月的数据全部保存在了一起 -``` - -可以看到,数组的定义方式也比较简单: - -```c -类型 数组名称[数组大小] = {数据1, 数据2...}; //后面的数据可以在一开始的时候不赋值,并且数组大小必须是整数 -``` - -注意数组只能存放指定类型的数据,一旦确定是不能更改的,因为数组声明后,会在内存中开辟一块连续的区域,来存放这些数据,所以类型和长度必须在一开始就明确。 - -![image-20220613113423268](https://s2.loli.net/2022/06/17/ESJ5WmydXrxfwsU.jpg) - -创建数组的方式有很多种: - -```c -int a[10]; //直接声明int类型数组,容量为10 - -int b[10] = {1, 2, 4}; //声明后,可以赋值初始值,使用{}囊括,不一定需要让10个位置都有初始值,比如这里仅仅是为前三个设定了初始值,注意,跟变量一样,如果不设定初始值,数组内的数据并不一定都是0 - -int c[10] = {1, 2, [4] = 777, [9] = 666}; //我们也可以通过 [下标] = 的形式来指定某一位的初始值,注意下标是从0开始的,第一个元素就是第0个下标位置,比如这里数组容量为10,那么最多到9 - -int c[] = {1, 2, 3}; //也可以根据后面的赋值来决定数组长度 -``` - -基本类型都可以声明数组: - -```c -#include - -int main() { - char str[] = {'A', 'B', 'C'}; //多个字符 - - char str2[] = "ABC"; //实际上字符串就是多个字符的数组形式,有关字符串我们会在下一节进行讲解 -} -``` - -那么数组定义好了,如何去使用它呢?比如我们现在需要打印12个月的天数: - -```c -#include - -int main() { - int arr[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; - for (int i = 0; i < 12; ++i) { - int days = arr[i]; //直接通过数组 名称[下标] 来访问对应的元素值,再次提醒,下标是从0开始的,不是1 - printf("2022年 %d 月的天数是:%d 天\n", (i + 1), days); - } -} -``` - -![image-20220613114739872](https://tva1.sinaimg.cn/large/e6c9d24egy1h36h0hctkdj21b204w0t5.jpg) - -当然我们也可以对数组中的值进行修改: - -```c -#include - -int main() { - int arr[] = {666, 777, 888}; - arr[1] = 999; //比如我们现在想要让第二个元素的值变成999 - printf("%d", arr[1]); //打印一下看看是不是变成了999 -} -``` - -![image-20220613114928435](https://s2.loli.net/2022/06/17/Q4bnsaIyz7NXReF.jpg) - -注意,和变量一样,如果只是创建数组但是不赋初始值的话,因为是在内存中随机申请的一块空间,有可能之前其他地方使用过,保存了一些数据,所以数组内部的元素值并不一定都是0: - -```c -#include - -int main() { - int arr[10]; - for (int i = 0; i < 10; ++i) { - printf("%d, ", arr[i]); - } -} -``` - -![image-20220613115108971](https://s2.loli.net/2022/06/17/63BLiUNexSjJgym.jpg) - -不要尝试去访问超出数组长度位置的数据,虽然可以编译通过,但是会给警告,这些数据是毫无意义的: - -```c -#include - -int main() { - int arr[] = {111, 222, 333}; - printf("%d", arr[3]); //不能去访问超出数组长度的元素,很明显这里根本就没有第四个元素 -} -``` - -### 多维数组 - -数组不仅仅只可以有一个维度,我们可以创建二维甚至多维的数组,简单来说就是,存放数组的数组(套娃了属于是): - -```c -int arr[][2] = {{20, 10}, {18, 9}}; //可以看到,数组里面存放的居然是数组 -//存放的内层数组的长度是需要确定的,存放数组的数组和之前一样,可以根据后面的值决定 -``` - -比如现在我们要存放2020-2022年每个月的天数,那么此时用一维数组肯定是不方便了,我们就可以使用二维数组来处理: - -```c -int arr[3][12] = {{31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}, //2020年是闰年,2月有29天 - {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}, - {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}}; -``` - -这样,我们就通过二维数组将这三年每个月的天数都保存下来了。 - -![image-20220613121156024](https://tva1.sinaimg.cn/large/e6c9d24egy1h36hpql8tpj211s05w3yp.jpg) - -那么二维数组又该如何去访问呢? - -```c -#include - -int main() { - int arr[3][12] = {{31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}, //2020年是闰年,2月有29天 - {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}, - {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}}; - printf("%d", arr[0][1]); //比如现在我们想要获取2020年2月的天数,首先第一个是[0]表示存放的第一个数组,第二个[1]表示数组中的第二个元素 -} -``` - -当然除了二维还可以上升到三维、四维: - -```c -int arr[2][2][2] = {{{1, 2}, {1, 2}}, {{1, 2}, {1, 2}}}; -``` - -有关多维数组,暂时先介绍到这里。 - -### 实战:冒泡排序算法 - -现在有一个int数组,但是数组内的数据是打乱的,现在请你通过C语言,实现将数组中的数据按**从小到大**的顺序进行排列: - -```c -#include - -int main() { - int arr[10] = {3, 5, 7, 2, 9, 0, 6, 1, 8, 4}; //乱序的 - //请编写代码对以上数组进行排序 -} -``` - -这里我们使用冒泡排序算法来实现,此算法的核心思想是: - -* 假设数组长度为N -* 进行N轮循环,每轮循环都选出一个最大的数放到后面。 -* 每次循环中,从第一个数开始,让其与后面的数两两比较,如果更大,就交换位置,如果更小,就不动。 - -动画演示:https://visualgo.net/zh/sorting?slide=2-2 - -### 实战:斐波那契数列解法其二 - -学习了数组,我们来看看如何利用数组来计算斐波那契数列,这里采用动态规划的思想。 - -> 动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有[最优值](https://baike.baidu.com/item/最优值)的解。动态规划算法与[分治法](https://baike.baidu.com/item/分治法)类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从[这些子](https://baike.baidu.com/item/这些子)问题的解得到原问题的解。 - -我们可以在一开始创建一个数组,然后从最开始的条件不断向后推导,从斐波那契数列的规律我们可以得知: - -* `fib[i] = fib[i - 1] + fib[i - 2]`(这里`fib`代表斐波那契数列) - -得到这样的一个关系(递推方程)就好办了,我们要求解数列第`i`个位置上的数,只需要知道`i - 1`和`i - 2`的值即可,这样,一个大问题,就分成了两个小问题,比如现在我们要求解斐波那契数列的第5个元素: - -* `fib[4] = fib[3] + fib[2]`现在我们只需要知道`fib[3]`和`fib[2]`即可,那么我们接着来看: -* `fib[3] = fib[2] + fib[1]`以及`fib[2] = fib[1] + fib[0]` -* 由于`fib[0]`和`fib[1]`我们已经明确知道是`1`了,那么现在问题其实已经有结果了,把这些小问题的结果组合起来不就能得到原来大问题的结果了吗? - -现在请你设计一个C语言程序,利用动态规划的思想解决斐波那契数列问题。 - -### 实战:打家劫舍 - -我们继续通过一道简单的算法题来强化动态规划思想。 - -**来源:力扣(LeetCode)No.198 打家劫舍**:https://leetcode.cn/problems/house-robber/ - ->你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。 -> ->给定一个代表每个房屋存放金额的非负整数数组,计算你 **不触动警报装置的情况下** ,一夜之内能够偷窃到的最高金额。 - -**示例 1:** - -> 输入:[1,2,3,1] -> 输出:4 -> 解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。 -> 偷窃到的最高金额 = 1 + 3 = 4 。 - -**示例 2:** - -> 输入:[2,7,9,3,1] -> 输出:12 -> 解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。 -> 偷窃到的最高金额 = 2 + 9 + 1 = 12 。 - -![image-20220613124415156](https://s2.loli.net/2022/06/17/WvZltqPwyUEhkJY.jpg) - -这道题我们也可以很轻松地按照上面的动态规划思路来处理,首先我们可以将问题分为子问题,比如现在有`[2,7,9,3,1]`五个房屋,这个问题看起来比较复杂,我们不妨先将大问题先简化成小问题,我们来看看只有N个房屋的情况: - -* 假设现在只有`[2]`这一个房屋,那么很明显,我可以直接去偷一号房,得到2块钱,所以当有一个房子时最大能偷到2块钱。 -* 假设现在有`[2, 7]`这两个房屋,那么很明显,我可以直接去偷二号房,得到7块钱,所以当有两个房子时最大能偷到7块钱。 -* 假设现在只有`[2, 7, 9]`这三个房屋,我们就要来看看了,是先偷一号房再偷三号房好,还是只偷二号房好,根据前面的结论,如果我们偷了一号房,那么就可以继续偷三号房,并且得到的钱就是从一号房过来的钱+三号房的钱,也就是2+9块钱,但是如果只偷二号房的话,那么就只能得到7块钱,所以,三号房能够偷到的最大金额有以下关系(dp是我们求出的第i个房屋的最大偷钱数量,value表示房屋价值,max表示取括号中取最大的一个): - * `dp[i] = max(dp[i - 1], dp[i - 2] + value[i])` -> **递推方程已得到** -* 这样就不难求出:`dp[2] = max(dp[1], dp[0] + value[i])` = `dp[2] = max(7, 2 + 9)` = `dp[2] = 11`,所以有三个房屋时最大的金额是11块钱。 -* 所以,实际上我们只需要关心前面计算出来的盗窃最大值即可,而不需要关心前面到底是怎么在偷。 -* 我们以同样的方式来计算四个房屋`[2, 7, 9, 3]`的情况: - * `dp[3] = max(dp[2], dp[1] + value[3])` = `dp[3] = max(11, 7 + 3)` = `dp[3] = 11` -* 所以,当有四个房屋时,我们依然采用先偷一后偷三的方案,不去偷四号,得到最大价值11块钱。 - -好了,现在思路已经出来了,我们直接上算法吧,现在请你实现下面的C语言程序: - -```c -#include - -int main() { - int arr[] = {2,7,9,3,1}, size = 5, result; - - //请补充程序 - - printf("%d", result); -} -``` - -力扣提交,建议各位小伙伴学习了函数和指针之后再回来看看,这里暂时可以跳过。 - -![image-20220613130702396](https://tva1.sinaimg.cn/large/e6c9d24egy1h36jb2k9ldj20yy06kq3h.jpg) - -```c -int max(int a, int b) { - return a > b ? a : b; -} - -int rob(int* nums, int numsSize){ - if(numsSize == 0) return 0; - if(numsSize == 1) return nums[0]; - if(numsSize == 2) return max(nums[1], nums[0]); - - int dp[numsSize]; - dp[0] = nums[0]; - dp[1] = max(nums[1], nums[0]); - - for (int i = 2; i < numsSize; ++i) { - dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]); - } - - return dp[numsSize - 1]; -} -``` - -*** - -## 字符串 - -前面我们学习了数组,而对于字符类型的数组,比较特殊,它实际上可以作为一个字符串(String)表示,字符串就是一个或多个字符的序列,比如我们在一开始认识的`"Hello World!"`,像这样的多个字符形成的一连串数据,就是一个字符串,而`printf`函数接受的第一个参数也是字符串。 - -那么,我们就来认识一下字符串。 - -### 字符串的创建和使用 - -在C语言中并没有直接提供存储字符串的类型,我们熟知的能够存储字符的只有char类型,但是它只能存储单个字符,而一连串的字符想要通过变量进行保存,那么就只能依靠数组了,char类型的数组允许我们存放多个字符,这样的话就可以表示字符串了。 - -比如我们现在想要存储`Hello`这一连串字符: - -```c -char str[] = {'H', 'e', 'l', 'l', 'o', '\0'}; //直接保存单个字符,但是注意,无论内容是什么,字符串末尾必须添加一个‘\0’字符(ASCII码为0)表示结束。 -printf("%s", str); //用%s来作为一个字符串输出 -``` - -不过这样写起来实在是太麻烦了,我们可以使用更加简便的写法: - -```c -char str[] = "Hello"; //直接使用双引号将所有的内容囊括起来,并且也不需要补充\0(但是本质上是和上面一样的字符数组) -//也可以添加 const char str[] = "Hello World!"; 双引号囊括的字符串实际上就是一个const char数组类型的值 -printf("%s", str); -``` - -这下终于明白了,原来我们一直在写的双引号,其实表示的就是一个字符串。 - -那么现在请各位小伙伴看看下面的写法有什么不同: - -```c -"c" -'c' -``` - -我们发现一个问题,char类型只能保存ASCII编码表中的字符,但是我们发现实际上中文也是可以正常打印的: - -```c -printf("你这瓜保熟吗"); -``` - -![image-20220616114344138](https://s2.loli.net/2022/06/17/jC9Vn4MbgOzt7LB.jpg) - -这是什么情况?那么多中文字符(差不多有6000多个),用ASCII编码表那128个肯定是没办法全部表示的,但是我们现在需要在电脑中使用中文。这时,我们就需要扩展字符集了。 - -> 我们可以使用两个甚至多个字节来表示一个中文字符,这样我们能够表示的数量就大大增加了,GB2132方案规定当连续出现两个大于127的字节时(注意不考虑符号位,此时相当于是第一个bit位一直为1了),表示这是一个中文字符(所以为什么常常有人说一个英文字符占一字节,一个中文字符占两个字节),这样我们就可以表示出超过7000种字符了,不仅仅是中文,甚至中文标点、数学符号等,都可以被正确的表示出来。 -> -> ```c -> 10000011 10000110 //这就是一个连续出现都大于127的字节(注意这里是不考虑符号位的) -> ``` -> -> 不过这样能够表示的内容还是不太够,除了那些常见的汉字之外,还有很多的生僻字,比如龘、錕、釿、拷这类的汉字,后来干脆直接只要第一个字节大于127,就表示这是一个汉字的开始,无论下一个字节是什么内容(甚至原来的128个字符也被编到新的表中),这就是Windows至今一直在使用的默认GBK编码格式。 -> -> 虽然这种编码方式能够很好的解决中文无法表示的问题,但是由于全球还有很多很多的国家以及很多很多种语言,所以我们的最终目标是能够创造一种可以表示全球所有字符的编码方式,整个世界都使用同一种编码格式,这样就可以同时表示全球的语言了。所以这时就出现了一个叫做ISO的(国际标准化组织)组织,来定义一套编码方案来解决所有国家的编码问题,这个新的编码方案就叫做Unicode,规定每个字符必须使用俩个字节,即用16个bit位来表示所有的字符(也就是说原来的那128个字符也要强行用两位来表示) -> -> 但是这样的话实际上是很浪费资源的,因为这样很多字符都不会用到两字节来保存,但是又得这样去表示,这就导致某些字符浪费了很多空间。所以最后就有了UTF-8编码格式,区分每个字符的开始是根据字符的高位字节来区分的,比如用一个字节表示的字符,第一个字节高位以“0”开头;用两个字节表示的字符,第一个字节的高位为以“110”开头,后面一个字节以“10开头”;用三个字节表示的字符,第一个字节以“1110”开头,后面俩字节以“10”开头;用四个字节表示的字符,第一个字节以“11110”开头,后面的三个字节以“10”开头: -> -> | Unicode符号范围(十六进制) | UTF-8编码方式(二进制) | -> | --------------------------- | ----------------------------------- | -> | 0000 0000 ~ 0000 007F | 0xxxxxxx | -> | 0000 0080 ~ 0000 07FF | 110xxxxx 10xxxxxx | -> | 0000 0800 ~ 0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx | -> | 0001 0000 ~ 0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx | -> -> 所以如果我们的程序需要表示多种语言,最好采用UTF-8编码格式。 - -简而言之,我们的中文实际上是依靠多个char来进行表示的。 - -![image-20220616134302528](https://s2.loli.net/2022/06/17/yJjkVABhZCa5Dx2.jpg) - -![image-20220616134330805](https://s2.loli.net/2022/06/17/ckD2LWU7MlfSTtb.jpg) - -这样,我们就了解了字符串的使用。 - -### scanf、gets、puts函数 - -函数我们会在下一章详细介绍,不过这里还是要再提到一个比较重要的函数。 - -前面我们认识了`printf`函数,实际上这个函数就是用于打印字符串到控制台,我们只需要填入一个字符串和后续的参数即可。 - -```c -#include - -int main() { - const char str[] = "Hello World!"; //注意printf需要填写一个const char数组进去,也就是字符串 - printf(str); -} -``` - -现在我们知道该如何输出,那么输入该如何实现呢,比如我们现在希望将我们想要说的话告诉程序,让程序从控制台读取我们输入的内容,这时我们就需要使用到`scanf`函数了: - -```c -#include - -int main() { - char str[10]; - scanf("%s", str); //使用scanf函数来接受控制台输入,并将输入的结果按照格式,分配给后续的变量 - //比如这里我们想要输入一个字符串,那么依然是使用%s(和输出是一样的占位符),后面跟上我们要赋值的数组(存放输入的内容) - printf("输入的内容为:%s", str); -} -``` - -可以看到,成功接收到用户输入: - -![image-20220616141313060](https://s2.loli.net/2022/06/17/RoEjaFVL4P9fWil.jpg) - -当然除了能够扫描成字符串之外,我们也可以直接扫描为一个数字: - -```c -#include - -int main() { - int a, b; - scanf("%d", &a); //连续扫描两个int数字 - scanf("%d", &b); //注意,如果不是数组类型,那么这里在填写变量时一定要在前面添加一个&符号(至于为什么,下一章在指针小节中会详细介绍)这里的&不是做与运算,而是取地址操作。 - - printf("a + b = %d", a + b); //扫描成功后,我们来计算a + b的结果 -} -``` - -除了使用`scanf`之外,我们也可以使用字符串专用的函数来接受字符串类型的输入和输出: - -```c -#include - -int main() { - char str[10]; - gets(str); //gets也是接收控制台输入,然后将结果丢给str数组中 - puts(str); //puts其实就是直接打印字符串到控制台 -} -``` - -当然也有专门用于字符输入输出的函数: - -```c -#include - -int main() { - int c = getchar(); - putchar(c); -} -``` - -由于我们目前还没有学习函数,所以这里稍微提及一下即可。 - -### 实战:回文串判断 - -“回文串”是一个正读和反读都一样的字符串,请你实现一个C语言程序,判断用户输入的字符串(仅出现英文字符)是否为“回文”串。 - -> ABCBA 就是一个回文串,因为正读反读都是一样的 -> -> ABCA 就不是一个回文串,因为反着读不一样 - -### 实战:字符串匹配KMP算法 - -现在有两个字符串: - -> str1 = "abcdabbc" -> -> str2 = "cda" - -现在请你设计一个C语言程序,判断第一个字符串中是否包含了第二个字符串,比如上面的例子中,很明显第一个字符串包含了第二个字符串。 - -* 暴力解法 -* KMP算法 - -有关C语言的基础部分内容,我们就讲解到这里,从下一章开始,难度将会有一定的提升,所以请各位小伙伴务必将本章知识点梳理清楚,牢记心中。 \ No newline at end of file diff --git a/青空笔记/Docker笔记/Docker容器技术.md b/青空笔记/Docker笔记/Docker容器技术.md deleted file mode 100644 index 05dd51e..0000000 --- a/青空笔记/Docker笔记/Docker容器技术.md +++ /dev/null @@ -1,1791 +0,0 @@ -![image-20220629215534772](https://s2.loli.net/2022/06/29/bnXgrjtzkx7YaLo.png) - -# Docker容器技术 - -Docker是一门平台级别的技术,涉及的范围很广,所以,在开始之前,请确保你完成:**Java SpringBoot 篇**(推荐完成SpringCloud篇再来)视频教程及之前全部路线,否则学习会非常吃力,另外推荐额外掌握:《计算机网络》、《操作系统》相关知识。学一样东西不能完全靠记忆来完成,而是需要结合自己所学的基础知识加以理解,一般来说,单凭记忆能够掌握的东西往往是最廉价的。 - -**Docker官网:**https://www.docker.com - -**课前准备:**配置2C2G以上Linux服务器一台,云服务器、虚拟机均可。 - -## 容器技术入门 - -随着时代的发展,Docker也逐渐走上了历史舞台,曾经我们想要安装一套环境,需要花费一下午甚至一整天来配置和安装各个部分(比如运行我们自己的SpringBoot应用程序,可能需要安装数据库、安装Redis、安装MQ等,各种各样的环境光是安装就要花费很多时间,真的是搞得心态爆炸),而有了Docker之后,我们的程序和环境部署就变得非常简单了,我们只需要将这些环境一起打包成一个镜像。而到服务器上部署时,可以直接下载镜像实现一键部署,是不是很方便? - -包括我们在学习SpringCloud需要配置的各种组件,可能在自己电脑的环境中运行会遇到各种各样的问题(可能由于电脑上各种环境没配置,导致无法运行),而现在只需要下载镜像就能直接运行,所有的环境全部在镜像中配置完成,开箱即用。 - -真的有这么神奇吗?我们来试试看。 - -### 环境安装和部署 - -首先我们还是先将Docker环境搭建好(建议和我同一个环境,不然出了问题只能自己想办法了),这里我们使用: - -* Ubuntu 22.04 操作系统 - -Docker分为免费的CE(Community Edition)社区版本和EE(Enterprise Edition)企业级付费版本,所以我们这里选择docker-ce进行安装。官方安装文档:https://docs.docker.com/engine/install/ubuntu/ - -首先安装一些工具: - -```sh -sudo apt-get install ca-certificates curl gnupg lsb-release -``` - -不过在Ubuntu22.04已经默认安装好了。接着安装官方的GPG key: - -```sh -sudo mkdir -p /etc/apt/keyrings - -curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg -``` - -最后将Docker的库添加到apt资源列表中: - -```sh -echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null -``` - -接着我们更新一次apt: - -```sh - sudo apt update -``` - -最后安装Docker CE版本: - -```sh - sudo apt install docker-ce -``` - -等待安装完成就可以了: - -![image-20220630161240162](https://s2.loli.net/2022/06/30/D1GXAQdUsgmTawq.png) - -![image-20220630161341541](https://s2.loli.net/2022/06/30/oI26yQiqhABN3UP.png) - -可以看到安装成功后版本是20.10.17,当然可能你们安装的时候就是更新的版本了。最后我们将当前用户添加到docker用户组中,不然每次使用docker命令都需要sudo执行,很麻烦: - -```sh -sudo usermod -aG docker <用户名> -``` - -配置好后,我们先退出SSH终端,然后重新连接就可以生效了。 - -这样我们Docker 的学习环境就配置好了,现在我们就尝试通过Docker来部署一个Nginx服务器试试看,使用很简单,只需要一个命令就可以了(当然现在看不懂没关系,我们后面会细嗦): - -```sh -sudo docker run -d -p 80:80 nginx -``` - -![image-20220630165259663](https://s2.loli.net/2022/06/30/sPVpLI9bXlzdKeO.png) - -首选它会从镜像仓库中下载对应的镜像,国内访问速度还行,不需要单独配置镜像源。接着下载完成后,就会在后台运行了,我们可以使用浏览器访问试试看: - -![image-20220630165430159](https://s2.loli.net/2022/06/30/fP5TsQnqUbmXoaA.png) - -![image-20220630165440751](https://s2.loli.net/2022/06/30/lPZYrUn2D1gNjx8.png) - -可以看到,Nginx服务器已经成功部署了,但是实际上我们并没有在Ubuntu中安装Nginx,而是通过Docker运行的镜像来进行服务器搭建的,是不是感觉玩法挺新奇的。除了Nginx这种简单的应用之外,我们还可以通过Docker来部署复杂应用,之后我们都会一一进行讲解的。 - -### 从虚拟机到容器 - -前面我们成功安装了Docker学习环境,以及浅尝了一下Docker为我们带来的应用快速部署。在正式进入学习之前,我们就先从Docker的发展开始说起。 - -在Docker出现之前,虚拟化技术可以说是占据了主导地位。首先我们来谈谈为什么会出现虚拟化技术,我们知道在企业中服务器可以说是必不可少的一种硬件设施了,服务器也是电脑,但是不像我们的家用电脑,服务器的配置是非常高的,我们家用电脑的CPU可能最高配也就20核了,内存很少有超过128G的电脑,64G内存的家用电脑可以算奢侈了。而服务器不一样,服务器级别的CPU动辄12核,甚至服务器还能同时安装多块CPU,能直接堆到好几十核: - -![image-20220630171220207](https://s2.loli.net/2022/06/30/DPxA9MsZ3WGl62X.png) - -我们家用级CPU一般是AMD的锐龙系列和Intel的酷睿系列(比如i3 i5 i7 i9),而服务器CPU一般是Intel的志强(Xeno)系列,这种CPU的特点就是核心数非常多: - -![image-20220630172135408](https://s2.loli.net/2022/06/30/cKlhRZ9Sw1Q4uEX.png) - -并且服务器CPU相比家用CPU的功耗也会更大,因此服务器CPU的发热量非常高,如果你有幸去过机房,你会听见散热风扇猛烈转动的声音(但是服务器CPU的频率没有家用级CPU高,一般大型游戏要求的是高频率而不是核心数,而且功耗也比较大,所以并不适合做家用电脑,所以以后在网上买台式机,看到什么“i9级”CPU千万别买,是这些黑心商家把国外服务器上淘汰下来的服务器CPU(洋垃圾)装成电脑卖给你,所以会很便宜,同时核心数又能媲美i9,所以还是一分钱一分货实在) - -服务器无论是CPU资源还是内存资源都远超家用电脑,而我们编写的Java后端项目,最后都会运行在这些服务器上,不过有一个问题,服务器既然有这么丰富的硬件资源,就跑咱们这一个小Java后端,是不是有点核弹炸蚊子的感觉了?可能顶多就用了服务器5%的硬件资源,服务器这么牛就运行个这也太浪费了吧。 - -所以,为了解决这种资源利用率只有5%-15%的情况,咱们能不能想个办法,把这一台服务器分成多个小服务器使用,每个小服务器只分配一部分的资源,比如分一个小服务器出去,只给2个CPU核心和4G内存。但是由于设计上的问题,我们的电脑只能同时运行一个操作系统,那么怎么办呢?此时虚拟化技术就开始兴起了。 - -虚拟化使用软件来模拟硬件并创建虚拟计算机系统。这样一来,企业便可以在单台服务器上运行多个虚拟系统,也就是运行多个操作系统和应用,而这可以实现规模经济以及提高效益。比如我们电脑上经常使用的VMware就是一种民用级虚拟化软件: - -![image-20220630173915254](https://s2.loli.net/2022/06/30/St3hfELQHNdRZmA.png) - -我们可以使用VMware来创建虚拟机,这些虚拟机实际上都是基于我们当前系统上的VMware软件来运行的,当然VMware也有服务器专用的虚拟化软件,有了虚拟化之后,我们的服务器就像这样: - -![image-20220630174945749](https://s2.loli.net/2022/06/30/BmnC1xETQM4uRHO.png) - -相当于通过虚拟机模拟了很多来电脑出来,这样我们就可以在划分出来的多台虚拟机上分别安装系统和部署我们的应用程序了,并且我们可以自由分配硬件资源,合理地使用。一般在企业中,不同的应用程序可能会被分别部署到各个服务器上,隔离开来,此时使用虚拟机就非常适合。 - -实际上我们在什么腾讯云、阿里云租的云服务器,都是经过虚拟化技术划分出来的虚拟机而已。 - -那么,既然虚拟机都这么方便了,容器又是怎么杀出一条血路的呢?我们先来看看什么是容器。 - -容器和虚拟机比较类似,都可以为应用提供封装和隔离,都是软件,但是容器中的应用运行是寄托于宿主操作系统的,实际上依然是在直接使用操作系统的资源,当然应用程序之间环境依然是隔离的,而虚拟机则是完全模拟一台真正的电脑出来,直接就是两台不同的电脑。 - -![image-20220630181037698](https://s2.loli.net/2022/06/30/31GZSh5DE9Vilet.png) - -因此容器相比虚拟机就简单多了,并且启动速度也会快很多,开销小了不少。 - -不过容器火的根本原因还是它的集装箱思想,我们知道,如果我们要写一个比如论坛、电商这类的Java项目,那么数据库、消息队列、缓存这类中间件是必不可少的,因此我们如果想要将一个服务部署到服务器,那么实际上还要提前准备好各种各样的环境,先安装好MySQL、Redis、RabbitMQ等应用,配置好了环境,再将我们的Java应用程序启动,整个流程下来,光是配置环境就要浪费大量的时间,如果是大型的分布式项目,可能要部署很多台机器,那岂不是我们得一个一个来?项目上个线就要花几天时间,显然是很荒唐的。 - -而容器可以打包整个环境,比较MySQL、Redis等以及我们的Java应用程序,可以被一起打包为一个镜像,当我们需要部署服务时,只需要像我们之前那样,直接下载镜像运行即可,不需要再进行额外的配置了,整个镜像中环境是已经配置好的状态,开箱即用。 - -![image-20220630182136717](https://s2.loli.net/2022/06/30/NTnU8iSj51CspFw.png) - -而我们要重点介绍的就是Docker了,可以看到它的图标就是一只鲸鱼,鲸鱼的上面是很多个集装箱,每个集装箱就是我们的整个环境+应用程序,Docker可以将任何应用及其依赖打包为一个轻量级,可移植,自包含的容器,容器可以运行在几乎所有的操作系统上。 - -### 容器工作机制简述 - -我们先来看看Docker的整体架构: - -![image-20220630184857540](https://s2.loli.net/2022/06/30/PeaxwNQXkiYSlUv.png) - -实际上分为三个部分: - -* Docker 客户端:也就是我们之前使用的docker命令,都是在客户端上执行的,操作会发送到服务端上处理。 -* Docker 服务端:服务端就是启动容器的主体了,一般是作为服务在后台运行,支持远程连接。 -* Registry:是存放Docker镜像的仓库,跟Maven一样,也可以分公有和私有仓库,镜像可以从仓库下载到本地存放。 - -当我们需要在服务器上部署一个已经打包好的应用和环境,我们只需要下载打包好的镜像就可以了,我们前面执行了: - -```sh -sudo docker run -d -p 80:80 nginx -``` - -实际上这个命令输入之后: - -1. Docker客户端将操作发送给服务端,告诉服务端我们要运行nginx这个镜像。 -2. Docker服务端先看看本地有没有这个镜像,发现没有。 -3. 接着只能从公共仓库Docker Hub去查找下载镜像了。 -4. 下载完成,镜像成功保存到本地。 -5. Docker服务端加载Nginx镜像,启动容器开始正常运行(注意容器和其他容器之间,和外部之间,都是隔离的,互不影响) - -所以,整个流程中,Docker就像是一搜运输船,镜像就像是集装箱,通过运输船将世界各地的货物送往我们的港口,货物到达港口后,Docker并不关心集装箱里面的是什么,只需要创建容器开箱即用就可以了。相比我们传统的手动安装配置环境,不知道方便了几个层次。 - -不过容器依然是寄托于宿主主机的运行的,所以一般在生产环境下,都是通过虚拟化先创建多台主机,然后再到各个虚拟机中部署Docker,这样的话,运维效率就大大提升了。 - -从下一章开始,我们就正式地来学习一下Docker的各种操作。 - -*** - -## 容器与镜像 - -要启动容器最关键的就是镜像,我们来看看镜像相关的介绍。 - -### 初识容器镜像 - -首先我们来了解一下镜像的相关操作,比如现在我们希望把某个镜像从仓库下载到本地,这里使用官方的hello-world镜像: - -```sh -docker pull hello-world -``` - -只需要输入`pull`命令,就可以直接下载到指定的镜像了: - -![image-20220701111043417](https://s2.loli.net/2022/07/01/tZ4S2HYvNKr7qiD.png) - -可以看到对上面一行有一句Using default tag,实际上一个镜像的名称是由两部分组成的,一个是`repository`,还有一个是`tag`,一般情况下约定`repository`就是镜像名称,`tag`作为版本,默认为latest,表示最新版本。所以指定版本运行的话: - -```sh -docker pull 名称:版本 -``` - -之后为了教学方便,我们就直接使用默认的tag,不去指定版本了。 - -镜像下载之后会存放在本地,要启动这个镜像的容器,实际上就像我们之前那样,输入`run`命令就可以了: - -```sh -docker run hello-world -``` - -当然如果仅仅是只想创建而不想马上运行的话,可以使用`create`命令: - -```sh -docker create hello-world -``` - -可以看到成功启动了: - -![image-20220701111314331](https://s2.loli.net/2022/07/01/Brl4cnK8WsjP7LV.png) - -启动之后,会使用当前镜像自动创建一个容器,我们可以输入`ps`命令来查看当前容器的容器列表: - -``` -docker ps -a -``` - -注意后面要加一个`-a`表示查看所有容器(其他选项可以使用-h查看),如果不加的话,只会显示当前正在运行的容器,而HelloWorld是一次性的不是Nginx那样的常驻程序,所以容器启动打印了上面的内容之后,容器就停止运行了: - -![image-20220701111840091](https://s2.loli.net/2022/07/01/zMN3TPR7aHu5YGb.png) - -可以看到容器列表中有我们刚刚创建的hello-world以及我们之前创建的nginx(注意同一个镜像可以创建多个容器),每个容器都有一个随机生成的容器ID写在最前面,后面是容器的创建时间以及当前的运行状态,最后一列是容器的名称,在创建容器时,名称可以由我们指定也可以自动生成,这里就是自动生成的。 - -我们可以手动指定名称启动,在使用`run`命令时,添加`--name`参数即可: - -```sh -docker run --name=lbwnb hello-world -``` - -![image-20220701125951980](https://s2.loli.net/2022/07/01/qOblnhr5CJiIBG6.png) - -我们可以手动开启处于停止状态的容器: - -```sh - docker start <容器名称/容器ID> -``` - -注意启动的对象我们要填写容器的ID或是容器的名称才可以,容器ID比较长,可以不写全只写一半,但是你要保证你输入的不完全容器ID是唯一的。 - -![image-20220701124845982](https://s2.loli.net/2022/07/01/XfFORtqhK9lcBi7.png) - -如果想要停止容器直接输入`stop`命令就可以了: - -```sh - docker stop <容器名称/容器ID> -``` - -或是重启: - -```sh - docker restart <容器名称/容器ID> -``` - -![image-20220701125025173](https://s2.loli.net/2022/07/01/Q2tor6KRIeUEhO4.png) - -如果我们不需要使用容器了,那么可以将容器删除,但是注意只有容器处于非运行状态时才可以删除: - -```sh -docker rm <容器名称/容器ID> -``` - -当然如果我们希望容器在停止后自动删除,我们可以在运行时添加`--rm`参数: - -```sh -docker run --rm 镜像名称 -``` - -![image-20220701125108834](https://s2.loli.net/2022/07/01/3MlPUpjoV1Qg8DX.png) - -删除后,容器将不复存在,当没有任何关于nginx的容器之后,我们可以删除nginx的本地镜像: - -![image-20220701125204728](https://s2.loli.net/2022/07/01/bmHqND36yCUBPVj.png) - -我们可以使用`images`命令来检查一下当前本地有那些镜像: - -```sh -docker images -``` - -![image-20220701125514145](https://s2.loli.net/2022/07/01/fEscbGJXw4e7YFK.png) - -至此,我们已经了解了Docker的简单使用,在后面的学习中,我们还会继续认识更多的玩法。 - -### 镜像结构介绍 - -前面我们了解了Docker的相关基本操作,实际上容器的基石就是镜像,有了镜像才能创建对应的容器实例,那么我们就先从镜像的基本结构开始说起,我们来看看镜像到底是个什么样的存在。 - -我们在打包项目时,实际上往往需要一个基本的操作系统环境,这样我们才可以在这个操作系统上安装各种依赖软件,比如数据库、缓存等,像这种基本的系统镜像,我们称为base镜像,我们的项目之后都会基于base镜像进行打包,当然也可以不需要base镜像,仅仅是基于当前操作系统去执行简单的命令,比如我们之前使用的hello-world就是。 - -一般base镜像就是各个Linux操作系统的发行版,比如我们正在使用的Ubuntu,还有CentOS、Kali等等。这里我们就下载一下CentOS的base镜像: - -```sh -docker pull centos -``` - -![image-20220701132622893](https://s2.loli.net/2022/07/01/oFKxiMzA3fs2aIl.png) - -可以看到,CentOS的base镜像就已经下载完成,不像我们使用完整系统一样,base镜像的CentOS省去了内核,所以大小只有272M,这里需要解释一下base镜像的机制: - -![image-20220701133111829](https://s2.loli.net/2022/07/01/dvmqAjKHkucbLFh.png) - -Linux操作体系由内核空间和用户空间组成,其中内核空间就是整个Linux系统的核心,Linux启动后首先会加`bootfs`文件系统,加载完成后会自动卸载掉,之后会加载用户空间的文件系统,这一层是我们自己可以进行操作的部分: - -* bootfs包含了BootLoader和Linux内核,用户是不能对这层作任何修改的,在内核启动之后,bootfs会自动卸载。 -* rootfs则包含了系统上的常见的目录结构,包括`/dev`、` /proc`、 `/bin`等等以及一些基本的文件和命令,也就是我们进入系统之后能够操作的整个文件系统,包括我们在Ubuntu下使用的apt和CentOS下使用的yum,都是用户空间上的。 - -base镜像底层会直接使用宿主主机的内核,也就是说你的Ubuntu内核版本是多少,那么base镜像中的CentOS内核版本就是多少,而rootfs则可以在不同的容器中运行多种不同的版本。所以,base镜像实际上只有CentOS的rootfs,因此只有300M大小左右,当然,CentOS里面包含多种基础的软件,还是比较臃肿的,而某些操作系统的base镜像甚至都不到10M。 - -使用`uname`命令可以查看当前内核版本: - -![image-20220701135056123](https://s2.loli.net/2022/07/01/mZjupCUktL7Ab2R.png) - -因此,Docker能够同时模拟多种Linux操作系统环境,就不足为奇了,我们可以尝试启动一下刚刚下载的base镜像: - -```sh -docker run -it centos -``` - -注意这里需要添加`-it`参数进行启动,其中`-i`表示在容器上打开一个标准的输入接口,`-t`表示分配一个伪tty设备,可以支持终端登录,一般这两个是一起使用,否则base容器启动后就自动停止了。 - -![image-20220701135834325](https://s2.loli.net/2022/07/01/13BYcCWHsDMrwvq.png) - -可以看到使用ls命令能够查看所有根目录下的文件,不过很多命令都没有,连clear都没有,我们来看看内核版本: - -![image-20220701140018095](https://s2.loli.net/2022/07/01/PtGwRWfXlTh67qm.png) - -可以看到内核版本是一样的(这也是缺点所在,如果软件对内核版本有要求的话,那么此时使用Docker就直接寄了),我们输入`exit`就可以退出容器终端了,可以看到退出后容器也停止了: - -![image-20220701140225415](https://s2.loli.net/2022/07/01/u5MQnWVihlbkyx1.png) - -当然我们也可以再次启动,注意启动的时候要加上`-i`才能进入到容器进行交互,否则会在后台运行: - -![image-20220701140706977](https://s2.loli.net/2022/07/01/QCsY5EyGSja6Khl.png) - -基于base镜像,我们就可以在这基础上安装各种各样的软件的了,几乎所有的镜像都是通过在base镜像的基础上安装和配置需要的软件构建出来的: - -![image-20220701143105247](https://s2.loli.net/2022/07/01/SDwEqz2b7lA9nJa.png) - -每安装一个软件,就在base镜像上一层层叠加上去,采用的是一种分层的结构,这样多个容器都可以将这些不同的层次自由拼装,比如现在好几个容器都需要使用CentOS的base镜像,而上面运行的软件不同,此时分层结构就很爽了,我们只需要在本地保存一份base镜像,就可以给多个不同的容器拼装使用,是不是感觉很灵活? - -我们看到除了这些软件之外,最上层还有一个可写容器层,这个是干嘛的呢,为什么要放在最上面? - -我们知道,所有的镜像会叠起来组成一个统一的文件系统,如果不同层中存在相同位置的文件,那么上层的会覆盖掉下层的文件,最终我们看到的是一个叠加之后的文件系统。当我们需要修改容器中的文件时,实际上并不会对镜像进行直接修改,而是在最顶上的容器层(最上面一般称为容器层,下面都是镜像层)进行修改,不会影响到下面的镜像,否则镜像就很难实现多个容器共享了。所以各个操作如下: - -* 文件读取:要读取一个文件,Docker会最上层往下依次寻找,找到后则打开文件。 -* 文件创建和修改:创建新文件会直接添加到容器层中,修改文件会从上往下依次寻找各个镜像中的文件,如果找到,则将其复制到容器层,再进行修改。 -* 删除文件:删除文件也会从上往下依次寻找各个镜像中的文件,一旦找到,并不会直接删除镜像中的文件,而是在容器层标记这个删除操作。 - -也就是说,我们对整个容器内的文件进行的操作,几乎都是在最上面的容器层进行的,我们是无法干涉到下面所有的镜像层文件的,这样就很好地保护了镜像的完整性,才能实现多个容器共享使用。 - -### 构建镜像 - -前面我们已经了解了Docker镜像的结构,实际上所有常用的应用程序都有对应的镜像,我们只需要下载这些镜像然后就可以使用了,而不需要自己去手动安装,顶多需要进行一些特别的配置。当然要是遇到某些冷门的应用,可能没有提供镜像,这时就要我们手动去安装,接着我们就来看看如何构建我们自己的Docker镜像。构建镜像有两种方式,一种是使用`commit`命令来完成,还有一种是使用Dockerfile来完成,我们先来看第一种。 - -这里我们就做一个简单的例子,比如我们现在想要在Ubuntu的base镜像中安装Java环境,并将其打包为新的镜像(这个新的镜像就是一个包含Java环境的Ubuntu系统镜像) - -咱们先启动Ubuntu镜像,然后使用`yum`命令(跟apt比较类似)来安装Java环境,首先是`run`命令: - -```sh -docker pull ubuntu -``` - -![image-20220701151405640](https://s2.loli.net/2022/07/01/tP5rhQuqfpxcRHL.png) - -接着启动: - -![image-20220701151433520](https://s2.loli.net/2022/07/01/l86G4dK71UwcZPi.png) - -直接使用apt命令来安装Java环境,在这之前先更新一下,因为是最小安装所以本地没有任何软件包: - -![image-20220701151600847](https://s2.loli.net/2022/07/01/RAzQr7P8C9aJwxK.png) - -接着输入: - -```sh -apt install openjdk-8-jdk -``` - -等待安装完成: - -![image-20220701152018041](https://s2.loli.net/2022/07/01/Fezitl7PDb19BL4.png) - -这样,我们就完成了对Java环境的安装了,接着我们就可以退出这个镜像然后将其构建为新的镜像: - -![image-20220701152130041](https://s2.loli.net/2022/07/01/LAIx5GYCJhsbmSo.png) - -使用`commit`命令可以将容器保存为新的镜像: - -```sh -docker commit 容器名称/ID 新的镜像名称 -``` - -![image-20220701152302171](https://s2.loli.net/2022/07/01/sbWLlEoMj2ZPcUV.png) - -![image-20220701152418060](https://s2.loli.net/2022/07/01/3q4juA8vOJew9W6.png) - -可以看到安装了软件之后的镜像大小比我们原有的大小大得多,这样我们就可以通过这个镜像来直接启动一个带Java环境的Ubuntu操作系统容器了。不过这种方式虽然自定义度很高,但是Docker官方并不推荐,这样的话使用者并不知道镜像是如何构建出来的,是否里面带了后门都不知道,并且这样去构建效率太低了,如果要同时构建多种操作系统的镜像岂不是要一个一个去敲?我们作为普通用户实际上采用Dokcerfile的方式会更好一些。 - -我们来看看如何使用Dockerfile的形式创建一个带Java环境的Ubuntu系统镜像。首先直接新建一个名为`Dockerfile`的文件: - -```sh -touch Dockerfile -``` - -接着我们来进行编辑,`Dockerfile`内部需要我们编写多种指令来告诉Docker我们的镜像的相关信息: - -```dockerfile -FROM <基础镜像> -``` - -首先我们需要使用FROM指令来选择当前镜像的基础镜像(必须以这个指令开始),这里我们直接使用`ubuntu`作为基础镜像即可,当然如果不需要任何基础镜像的话,直接使用`scratch`表示从零开始构建,这里就不演示了。 - -基础镜像设定完成之后,我们就需要在容器中运行命令来安装Java环境了,这里需要使用`RUN`指令: - -```dockerfile -RUN apt update -RUN apt install -y openjdk-8-jdk -``` - -每条指令执行之后,都会生成一个新的镜像层。 - -OK,现在我们的Dockerfile就编写完成了,只需要完成一次构建即可: - -```sh -docker build -t <镜像名称> <构建目录> -``` - -执行后,Docker会在构建目录中寻找Dockerfile文件,然后开始依次执行Dockerfile中的指令: - -![image-20220701155443170](https://s2.loli.net/2022/07/01/g6RFwA5t4EsdvnY.png) - -构建过程的每一步都非常清晰地列出来了,一共三条指令对应三步依次进行,我们稍微等待一段时间进行安装,安装过程中所以的日志信息会直接打印到控制台(注意Docker镜像构建有缓存机制,就算你现在中途退出了,然后重新进行构建,也会直接将之前已经构建好的每一层镜像,直接拿来用,除非修改了Dockerfile文件重新构建,只要某一层发生变化其上层的构建缓存都会失效,当然包括`pull`时也会有类似的机制) - -![image-20220701155812315](https://s2.loli.net/2022/07/01/foLHIZScQ1KVbvC.png) - -最后成功安装,会出现在本地: - -![image-20220701155847721](https://s2.loli.net/2022/07/01/95ueUgyaTcrz6Mi.png) - -可以看到安装出来的大小跟我们之前的是一样的,因为做的事情是一模一样的。我们可以使用`history`命令来查看构建历史: - -![image-20220701160128689](https://s2.loli.net/2022/07/01/GYyHFcjSKJwvWi6.png) - -可以看到最上面两层是我们通过使用apt命令生成的内容,就直接作为当前镜像中的两层镜像,每层镜像都有一个自己的ID,不同的镜像大小也不一样。而我们手动通过`commit`命令来生成的镜像没有这个记录: - -![image-20220701160406891](https://s2.loli.net/2022/07/01/qWUeSF3aKrvwJ8p.png) - -如果遇到镜像ID为missing的一般是从Docker Hub中下载的镜像会有这个问题,但是问题不大。用我们自己构建的镜像来创建容器就可以直接体验带Java环境的容器了: - -![image-20220701161546279](https://s2.loli.net/2022/07/01/STmdFvBIbN4VAl1.png) - -有关Dockerfile的其他命令,我们还会在后续的学习中逐步认识。 - -### 发布镜像到远程仓库 - -前面我们学习了如何构建一个Docker镜像,我们可以将自己的镜像发布到Docker Hub中,就像Git远程仓库一样,我们可以将自己的镜像上传到这里:https://hub.docker.com/repositories,没有账号的先去进行注册。 - -![image-20220701164609666](https://s2.loli.net/2022/07/01/3T8xJLgER4cWuQq.png) - -点击右上角的创建仓库,然后填写信息: - -![image-20220701164939268](https://s2.loli.net/2022/07/01/SkCKJmU6Rw2lfzP.png) - -创建完成后,我们就有了一个公共的镜像仓库,我们可以将本地的镜像上传了,上传之前我们需要将镜像名称修改得规范一点,这里使用`tag`命令来重新打标签: - -```sh -docker tag ubuntu-java-file:latest 用户名/仓库名称:版本 -``` - -这里我们将版本改成1.0版本吧,不用默认的latest了。 - -![image-20220701165231001](https://s2.loli.net/2022/07/01/chAPS2DFW5q7GkE.png) - -修改完成后,会创建一个新的本地镜像,名称就是我们自己定义的了。接着我们需在本地登录一下: - -![image-20220701165446859](https://s2.loli.net/2022/07/01/T3YC4pfaLEo85Oz.png) - -登录成功后我们就可以上传了: - -```sh -docker push nagocoler/ubuntu-java:1.0 -``` - -![image-20220701165744647](https://s2.loli.net/2022/07/01/CXoBhpZUl79aDRQ.png) - -哈哈,500M的东西传上去,还是有点压力的,如果实在太慢各位可以重新做一个简单点的镜像。上传完成后,打开仓库,可以看到已经有一个1.0版本了: - -![image-20220701165920060](https://s2.loli.net/2022/07/01/3UD9y8frEIX1JY6.png) - -![image-20220701170053250](https://s2.loli.net/2022/07/01/9sVSjcGCo5mTu61.png) - -注意公共仓库是可以被搜索和下载的,所以我们这里把本地的镜像全部删掉,去下载我们刚刚上传好的镜像。这里我们先搜索一下,搜索使用`search`命令即可: - -```sh -docker search nagocoler/ubuntu-java -``` - -![image-20220701170253126](https://s2.loli.net/2022/07/01/SIUpBOzN5vsiydn.png) - -我们可以使用pull命令将其下载下来: - -```sh -docker pull nagocoler/ubuntu-java:1.0 -``` - -![image-20220701171148334](https://s2.loli.net/2022/07/01/uXBk3WPsDM4aZKo.png) - -上传之后的镜像是被压缩过的,所以下载的内容就比较少一些。运行试试看: - -![image-20220701171253440](https://s2.loli.net/2022/07/01/RJVdstMnxjSYFoW.png) - -当然各位也可以让自己的同学或是在其他机器上尝试下载自己的镜像,看看是不是都可以正常运行。 - -Docker Hub也可以自行搭建私服,但是这里就不多做介绍了,至此,有关容器和镜像的一些基本操作就讲解得差不多了。 - -### 实战:使用IDEA构建SpringBoot程序镜像 - -这里我们创建一个新的SpringBoot项目,现在我们希望能够使用Docker快速地将我们的SpringBoot项目部署到安装了Docker的服务器上,我们就可以将其打包为一个Docker镜像。 - -![image-20220701173902376](https://s2.loli.net/2022/07/01/QObHMsxAtej6lPq.png) - -先创建好一个项目让它跑起来,可以正常运行就没问题了,接着我们需要将其打包为Docker镜像,这里创建一个新的Dockerfile: - -```dockerfile -FROM ubuntu -RUN apt update && apt install -y openjdk-8-jdk -``` - -首先还是基于ubuntu构建一个带Java环境的系统镜像,接着我们先将其连接到我们的Docker服务器进行构建,由于IDEA自带了Docker插件,所以我们直接点击左上角的运行按钮,选择第二项 **“为Dockerfile构建镜像”**: - -![image-20220701203741495](https://s2.loli.net/2022/07/01/xB5vEw1QHojWZ8p.png) - -![image-20220701202537650](https://s2.loli.net/2022/07/01/FAcME5yxZPD1aoz.png) - -这里需要配置Docker的服务器,也就是我们在Ubuntu服务器安装的Docker,这里我们填写服务器相关信息,我们首选需要去修改一下Docker的一些配置,开启远程客户端访问: - -```sh -sudo vim /etc/systemd/system/multi-user.target.wants/docker.service -``` - -打开后,添加高亮部分: - -![image-20220701202846707](https://s2.loli.net/2022/07/01/OVMDGqiYWU9E7fA.png) - -修改完成后,重启Docker服务,如果是云服务器,记得开启2375 TCP连接端口: - -```sh -sudo systemctl daemon-reload -sudo systemctl restart docker.service -``` - -现在接着在IDEA中进行配置: - -![image-20220701203318098](https://s2.loli.net/2022/07/01/bDn3vHFw1XYdusU.png) - -在引擎API URL处填写我们Docker服务器的IP地址: - -``` -tcp://IP:2375 -``` - -显示连接成功后,表示配置正确,点击保存即可,接着就开始在我们的Docker服务器上进行构建了: - -![image-20220701203518930](https://s2.loli.net/2022/07/01/nPFSa4Wcep31jXG.png) - -最后成功构建: - -![image-20220701204815069](https://s2.loli.net/2022/07/01/1qtCFZKbg6fJsok.png) - -可以看到,Docker服务器上已经有了我们刚刚构建好的镜像: - -![image-20220701204900943](https://s2.loli.net/2022/07/01/a6J43UW5biwTyVo.png) - -不过名称没有指定,这里我们重新配置一下: - -![image-20220701204955570](https://s2.loli.net/2022/07/01/edPVg4oyrDiqmk6.png) - -![image-20220701205053642](https://s2.loli.net/2022/07/01/1QrHVB4zC9iFTG7.png) - -重新进行构建,就是我们自定义的名称了: - -![image-20220701205402607](https://s2.loli.net/2022/07/01/qrWDZEKHklSU8OT.png) - -![image-20220701205350004](https://s2.loli.net/2022/07/01/6JKXLHEz25QGvMk.png) - -我们来创建一个容器试试看: - -![image-20220701205500494](https://s2.loli.net/2022/07/01/8xPUg7qmVzXF9nN.png) - -好了,现在基本环境搭建好了,我们接着就需要将我们的SpringBoot项目打包然后再容器启动时运行了,打开Maven执行打包命令: - -![image-20220701205630885](https://s2.loli.net/2022/07/01/CRLi2uJcXhzqPHF.png) - -接着我们需要编辑Dockerfile,将我们构建好的jar包放进去: - -```dockerfile -COPY target/DockerTest-0.0.1-SNAPSHOT.jar app.jar -``` - -这里需要使用COPY命令来将文件拷贝到镜像中,第一个参数是我们要拷贝的本地文件,第二个参数是存放在Docker镜像中的文件位置,由于还没有学习存储管理,这里我们直接输入`app.jar`直接保存在默认路径即可。 - -接着我们就需要指定在启动时运行我们的Java程序,这里使用CMD命令来完成: - -```dockerfile -FROM ubuntu -RUN apt update && apt install -y openjdk-8-jdk -COPY target/DockerTest-0.0.1-SNAPSHOT.jar app.jar -CMD java -jar app.jar -# EXPOSE 8080 -``` - -CMD命令可以设定容器启动后执行的命令,EXPOSE可以指定容器需要暴露的端口,但是现在我们还没有学习网络相关的知识,所以暂时不使用,这里指定为我们启动Java项目的命令。配置完成后,重新构建: - -![image-20220701210438145](https://s2.loli.net/2022/07/01/NgCLJbRQc1lMqna.png) - -可以看到历史中已经出现新的步骤了: - -![image-20220701213513862](https://s2.loli.net/2022/07/01/gpfn4EqjMbZh1Nd.png) - -接着启动我们的镜像,我们可以直接在IDEA中进行操作,不用再去敲命令了,有点累: - -![image-20220701210845768](https://s2.loli.net/2022/07/01/t2MV3Tu6IcrK8Dl.png) - -![image-20220701210908997](https://s2.loli.net/2022/07/01/JqajY8EdVbGNhiF.png) - -启动后可以在右侧看到容器启动的日志信息: - -![image-20220701210946261](https://s2.loli.net/2022/07/01/jreyMHzcX8LTh3k.png) - -![image-20220701211029119](https://s2.loli.net/2022/07/01/OGAj3Rr59iVLqfe.png) - -但是我们发现启动之后并不能直接访问,这是为什么呢?这是因为容器内部的网络和外部网络是隔离的,我们如果想要访问容器内的服务器,需要将对应端口绑定到宿主机上,让宿主主机也开启这个端口,这样才能连接到容器内: - -```sh -docker run -p 8080:8080 -d springboot-test:1.0 -``` - -这里`-p`表示端口绑定,将Docker容器内的端口绑定到宿主机的端口上,这样就可以通过宿主的8080端口访问到容器的8080端口了(有关容器网络管理我们还会在后面进行详细介绍),`-d`参数表示后台运行,当然直接在IDEA中配置也是可以的: - -![image-20220701211536598](https://s2.loli.net/2022/07/01/dXQlEBIDzU6YTLG.png) - -配置好后,点击重新创建容器: - -![image-20220701211701640](https://s2.loli.net/2022/07/01/6G7hbmW81uBsKFc.png) - -重新运行后,我们就可以成功访问到容器中运行的SpringBoot项目了: - -![image-20220701211753962](https://s2.loli.net/2022/07/01/7xNrfWcvC58hQ4q.png) - -当然,为了以后方便使用,我们可以直接将其推送到Docker Hub中,这里我们还是创建一个新的公开仓库: - -![image-20220701212330425](https://s2.loli.net/2022/07/01/oTXBtlPV7j3C6a9.png) - -这次我们就使用IDEA来演示直接进行镜像的上传,直接点击: - -![image-20220701212458851](https://s2.loli.net/2022/07/01/91tKnXDWaeFqcrx.png) - -接着我们需要配置一下我们的Docker Hub相关信息: - -![image-20220701212637581](https://s2.loli.net/2022/07/01/tMcD2kzNwW9J7d3.png) - -![image-20220701212731276](https://s2.loli.net/2022/07/01/kgTlz3m61ZrHx5s.png) - -OK,远程镜像仓库配置完成,直接推送即可,等待推送完成。 - -![image-20220701212902977](https://s2.loli.net/2022/07/01/H5UfWXC2nKVeray.png) - -可以看到远程仓库中已经出现了我们的镜像,然后IDEA中也可以同步看到: - -![image-20220701213026214](https://s2.loli.net/2022/07/01/mgRKV2SWb9YxBGr.png) - -这样,我们就完成了使用IDEA将SpringBoot项目打包为Docker镜像。 - -*** - -## 容器网络管理 - -**注意:**本小节学习需要掌握部分《计算机网络》课程中的知识。 - -前面我们学习了容器和镜像的一些基本操作,了解了如何通过镜像创建容器、然后自己构建容器,以及远程仓库推送等,这一部分我们接着来讨论容器的网络管理。 - -### 容器网络类型 - -Docker在安装后,会在我们的主机上创建三个网络,使用`network ls`命令来查看: - -```sh -docker network ls -``` - -![image-20220702161742741](https://s2.loli.net/2022/07/02/7KEumyqriRY2QU5.png) - -可以看到默认情况下有`bridge`、`host`、`none`这三种网络类型(其实有点像虚拟机的网络配置,也是分桥接、共享网络之类的),我们先来依次介绍一下,在开始之前我们先构建一个镜像,默认的ubuntu镜像由于啥软件都没有,所以我们把一会网络要用到的先提前装好: - -```sh -docker run -it ubuntu -``` - -```sh -apt update -apt install net-tools iputils-ping curl -``` - -这样就安装好了,我们直接退出然后将其构建为新的镜像: - -```sh -docker commit lucid_sammet ubuntu-net -``` - -![image-20220702170441267](https://s2.loli.net/2022/07/02/NIGfx25Un83EV7Q.png) - -OK,一会我们就可以使用了。 - -* **none网络:**这个网络除了有一个本地环回网络之外,就没有其他的网络了,我们可以在创建容器时指定这个网络。 - - 这里使用`--network`参数来指定网络: - - ```sh - docker run -it --network=none ubuntu-net - ``` - - 进入之后,我们可以直接查看一下当前的网络: - - ```sh - ifconfig - ``` - - 可以看到只有一个本地环回`lo`网络设备: - - ![image-20220702170000617](https://s2.loli.net/2022/07/02/qL1oAkOCcIYRwZj.png) - - 所以这个容器是无法连接到互联网的: - - ![image-20220702170531312](https://s2.loli.net/2022/07/02/xzSp4hTBkeFqCd3.png) - - “真”单机运行,可以说是绝对的安全,没人能访问进去,存点密码这些还是不错的。 - -* **bridge网络:**容器默认使用的网络类型,这是桥接网络,也是应用最广泛的网络类型: - - 实际上我们在宿主主机上查看网络信息,会发现有一个名为docker0的网络设备: - - ![image-20220702172102410](https://s2.loli.net/2022/07/02/jDKSIriXec96uhy.png) - - 这个网络设备是Docker安装时自动创建的虚拟设备,它有什么用呢?我们可以来看一下默认创建的容器内部的情况: - - ```sh - docker run -it ubuntu-net - ``` - - ![image-20220702172532004](https://s2.loli.net/2022/07/02/5JdimQWMaCx7hy2.png) - - 可以看到容器的网络接口地址为172.17.0.2,实际上这是Docker创建的虚拟网络,就像容器单独插了一根虚拟的网线,连接到Docker创建的虚拟网络上,而docker0网络实际上作为一个桥接的角色,一头是自己的虚拟子网,另一头是宿主主机的网络。 - - 网络拓扑类似于下面这样: - - ![image-20220702173005750](https://s2.loli.net/2022/07/02/xCKMIBwjq7gWOko.png) - - 通过添加这样的网桥,我们就可以对容器的网络进行管理和控制,我们可以使用`network inspect`命令来查看docker0网桥的配置信息: - - ```sh - docker network inspect bridge - ``` - - ![image-20220702173431530](https://s2.loli.net/2022/07/02/86XdZUejEuk1P3i.png) - - 这里的配置的子网是172.17.0.0,子网掩码是255.255.0.0,网关是172.17.0.1,也就是docker0这个虚拟网络设备,所以我们上面创建的容器就是这个子网内分配的地址172.17.0.2了。 - - 之后我们还会讲解如何管理和控制容器网络。 - -* **host网络:**当容器连接到此网络后,会共享宿主主机的网络,网络配置也是完全一样的: - - ```sh - docker run -it --network=host ubuntu-net - ``` - - 可以看到网络列表和宿主主机的列表是一样的,不知道各位有没有注意到,连hostname都是和外面一模一样的: - - ![image-20220702170754656](https://s2.loli.net/2022/07/02/cRAQtIxV4D9byCu.png) - - 只要宿主主机能连接到互联网,容器内部也是可以直接使用的: - - ![image-20220702171041631](https://s2.loli.net/2022/07/02/lVsc1mpihq54Pue.png) - - 这样的话,直接使用宿主的网络,传输性能基本没有什么折损,而且我们可以直接开放端口等,不需要进行任何的桥接: - - ```sh - apt install -y systemctl nginx - systemctl start nginx - ``` - - 安装Nginx之后直接就可以访问了,不需要开放什么端口: - - ![image-20220702171550979](https://s2.loli.net/2022/07/02/1JnY6KyVpXOwbtl.png) - - 相比桥接网络就方便得多了。 - -我们可以根据实际情况,来合理地选择这三种网络使用。 - -### 用户自定义网络 - -除了前面我们介绍的三种网络之外,我们也可以自定义自己的网络,让容器连接到这个网络。 - -Docker默认提供三种网络驱动:`bridge`、`overlay`、`macvlan`,不同的驱动对应着不同的网络设备驱动,实现的功能也不一样,比如bridge类型的,其实就和我们前面介绍的桥接网络是一样的。 - -我们可以使用`network create`来试试看: - -```sh -docker network create --driver bridge test -``` - -这里我们创建了一个桥接网络,名称为test: - -![image-20220702180837819](https://s2.loli.net/2022/07/02/piCtK8kdRALHSIu.png) - -可以看到新增了一个网络设备,这个就是一会负责我们容器网络的网关了,和之前的docker0是一样的: -```sh -docker network inspect test -``` - -![image-20220702181150667](https://s2.loli.net/2022/07/02/uLwAD4YC3UFXQt7.png) - -这里我们创建一个新的容器,使用此网络: - -```sh - docker run -it --network=test ubuntu-net -``` - -![image-20220702181252137](https://s2.loli.net/2022/07/02/Iy2BwDoZsLMO8gJ.png) - -成功得到分配的IP地址,是在这个网络内的,注意不同的网络之间是隔离的,我们可以再创建一个容器试试看: - -![image-20220702181808792](https://s2.loli.net/2022/07/02/b14dflKGMunULQI.png) - -可以看到不同的网络是相互隔离的,无法进行通信,当然我们也为此容器连接到另一个容器所属的网络下: - -```sh -docker network connect test 容器ID/名称 -``` - -![image-20220702182050204](https://s2.loli.net/2022/07/02/WzvhI63ydfeJStA.png) - -这样就连接了一个新的网络: - -![image-20220702182146049](https://s2.loli.net/2022/07/02/lxqrz36sVUjNdI4.png) - -可以看到容器中新增了一个网络设备连接到我们自己定义的网络中,现在这两个容器在同一个网络下,就可以相互ping了: -![image-20220702182310008](https://s2.loli.net/2022/07/02/WBlC9PheETO64xq.png) - -这里就不介绍另外两种类型的网络了,他们是用于多主机通信的,目前我们只学习单机使用。 - -### 容器间网络 - -我们首先来看看容器和容器之间的网络通信,实际上我们之前已经演示过ping的情况了,现在我们创建两个ubuntu容器: - -```sh -docker run -it ubuntu-net -``` - -先获取其中一个容器的网络信息: - -![image-20220702175353454](https://s2.loli.net/2022/07/02/yTEcg4l2kASBnQu.png) - -我们可以直接在另一个容器中ping这个容器: - -![image-20220702175444713](/Users/nagocoler/Library/Application Support/typora-user-images/image-20220702175444713.png) - -可以看到能够直接ping通,因为这两个容器都是使用的bridge网络,在同一个子网中,所以可以互相访问。 - -我们可以直接通过容器的IP地址在容器间进行通信,只要保证两个容器处于同一个网络下即可,虽然这样比较方便,但是大部分情况下,容器部署之后的IP地址是自动分配的(当然也可以使用`--ip`来手动指定,但是还是不方便),我们无法提前得知IP地址,那么有没有一直方法能够更灵活一些呢? - -我们可以借助Docker提供的DNS服务器,它就像是一个真的DNS服务器一样,能够对域名进行解析,使用很简单,我们只需要在容器启动时给个名字就行了,我们可以直接访问这个名称,最后会被解析为对应容器的IP地址,但是注意只会在我们用户自定义的网络下生效,默认的网络是不行的: - -```sh -docker run -it --name=test01 --network=test ubuntu-net -docker run -it --name=test02 --network=test ubuntu-net -``` - -接着直接ping对方的名字就可以了: - -![image-20220702192457354](https://s2.loli.net/2022/07/02/lKCFY6ec17N4b5y.png) - -可以看到名称会自动解析为对应的IP地址,这样的话就不用担心IP不确定的问题了。 - -当然我们也可以让两个容器同时共享同一个网络,注意这里的共享是直接共享同一个网络设备,两个容器共同使用一个IP地址,只需要在创建时指定: - -```sh -docker run -it --name=test01 --network=container:test02 ubuntu-net -``` - -这里将网络指定为一个容器的网络,这样两个容器使用的就是同一个网络了: - -![image-20220702200711351](https://s2.loli.net/2022/07/02/Wb6jODxFP3r1mE7.png) - -可以看到两个容器的IP地址和网卡的Mac地址是完全一样的,它们的网络现在是共享状态,此时在容器中访问,localhost,既是自己也是别人。 - -我们可以在容器1中,安装Nginx,然后再容器2中访问: - -```sh - apt install -y systemctl nginx - systemctl start nginx -``` - -![image-20220702201348722](https://s2.loli.net/2022/07/02/WTn9OMYmLZJXtBz.png) - -成功访问到另一个容器中的Nginx服务器。 - -### 容器外部网络 - -前面我们介绍了容器之间的网络通信,我们接着来看容器与外部网络的通信。 - -首先我们来看容器是如何访问到互联网的,在默认的三种的网络下,只有共享模式和桥接模式可以连接到外网,共享模式实际上就是直接使用宿主主机的网络设备连接到互联网,这里我们主要来看一下桥接模式。 - -通过前面的学习,我们了解到桥接模式实际上就是创建一个单独的虚拟网络,让容器在这个虚拟网络中,然后通过桥接器来与外界相连,那么数据包是如何从容器内部的网络到达宿主主机再发送到互联网的呢?实际上整个过程中最关键的就是依靠NAT(Network Address Translation)将地址进行转换,再利用宿主主机的IP地址发送数据包出去。 - -这里我们就来补充一下《计算机网络》课程中学习的NAT: - -实际上NAT在我们生活中也是经常见到的,比如我们要访问互联网上的某个资源,要和服务器进行通信,那么就需要将数据包发送出去,同时服务器也要将数据包发送回来,我们可以知道服务器的IP地址,也可以直接去连接,因为服务器的IP地址是暴露在互联网上的,但是我们的局域网就不一样了,它仅仅局限在我们的家里,比如我们连接了家里的路由器,可以得到一个IP地址,但是你会发现,这个IP公网是无法直接访问到我们的,因为这个IP地址仅仅是一个局域网的IP地址,俗称内网IP,既然公网无法访问到我们,那服务器是如何将数据包发送给我们的呢? - -![image-20220702230700124](https://s2.loli.net/2022/07/02/LxtQ68HzEVYKdjW.png) - -实际上这里就借助了NAT在帮助我们与互联网上的服务器进行通信,通过NAT,可以实现将局域网的IP地址,映射为对应的公网IP地址,而NAT设备一端连接外网,另一端连接内网的所有设备,当我们想要与外网进行通信时,就可以将数据包发送给NAT设备,由它来将数据包的源地址映射为它在外网上的地址,这样服务器就能够发现它了,能够直接与它建立通信。当服务器发送数据回来时,也是直接交给NAT设备,然后再根据地址映射,转发给对应的内网设备(当然由于公网IP地址有限,所以一般采用IP+端口结合使用的形式ANPT) - -所以你打开百度直接搜IP,会发现这个IP地址并不是你本地的,而是NAT设备的公网地址: - -![image-20220702231458928](https://s2.loli.net/2022/07/02/uAW9GH1b6xkDB3T.png) - -实际上我们家里的路由器一般都带有NAT功能,默认开启NAT模式,包括我们的小区也是有一个NAT设备在进行转换的,这样你的电脑才能在互联网的世界中遨游。当然NAT也可以保护内网的设备不会直接暴露在公网,这样也会更加的安全,只有当我们主动发起连接时,别人才能知道我们。 - -当然,我们的Docker也是这样的,实际上内网的数据包想要发送到互联网上去,那么就需要经过这样的一套流程: - -![image-20220702232449520](https://s2.loli.net/2022/07/02/ktEA5O9BrmxXbPz.png) - -这样,Docker容器使用的内网就可以和外网进行通信了。 - -但是这样有一个问题,单纯依靠NAT的话,只有我们主动与外界联系时,外界才能知道我们,但是现在我们的容器中可能会部署一些服务,需要外界来主动连接我们,此时该怎么办呢? - -我们可以直接在容器时配置端口映射,还记得我们在第一节课部署Nginx服务器吗? - -```sh -docker run -d -p 80:80 nginx -``` - -这里的`-p`参数实际上是进行端口映射配置,端口映射可以将容器需要对外提供服务的端口映射到宿主主机的端口上,这样,当外部访问到宿主主机的对应端口时,就会直接转发给容器内映射的端口了。规则为`宿主端口:容器端口`,这里配置的是将容器的80端口映射到宿主主机的80端口上。 - -![image-20220702233420287](https://s2.loli.net/2022/07/02/WQzEVTwePNaHYgG.png) - -一旦监听到宿主主机的80端口收到了数据包,那么会直接转发给对应的容器。所以配置了端口映射之后,我们才可以从外部正常访问到容器内的服务: - -![image-20220630165440751](https://s2.loli.net/2022/07/02/VY5imqeG9jlAz8d.png) - -我们也可以直接输入`docker ps`查看端口映射情况: - -![image-20220702233831651](https://s2.loli.net/2022/07/02/dyDhNRvQ7Bzixka.png) - -至此,有关容器的网络部分,就到此为止,当然这仅仅是单机下的容器网络操作,在以后的课程中,我们还会进一步学习多主机下的网络配置。 - -*** - -## 容器存储管理 - -前面我们介绍了容器的网络管理,我们现在已经了解了如何配置容器的网络,以及相关的一些原理。还有一个比较重要的部分就是容器的存储,在这一小节我们将深入了解容器的存储管理。 - -### 容器持久化存储 - -我们知道,容器在创建之后,实际上我们在容器中创建和修改的文件,实际上是被容器的分层机制保存在最顶层的容器层进行操作的,为了保护下面每一层的镜像不被修改,所以才有了这样的CopyOnWrite特性。但是这样也会导致容器在销毁时数据的丢失,当我们销毁容器重新创建一个新的容器时,所有的数据全部丢失,直接回到梦开始的地方。 - -在某些情况下,我们可能希望对容器内的某些文件进行持久化存储,而不是一次性的,这里就要用到数据卷(Data Volume)了。 - -在开始之前我们先准备一下实验要用到的镜像: - -```sh -docker run -it ubuntu -apt update && apt install -y vim -``` - -然后打包为我们一会要使用的镜像: - -``` -docker commit -``` - -我们可以让容器将文件保存到宿主主机上,这样就算容器销毁,文件也会在宿主主机上保留,下次创建容器时,依然可以从宿主主机上读取到对应的文件。如何做到呢?只需要在容器启动时指定即可: - -```sh -mkdir test -``` - -我们现在用户目录下创建一个新的`test`目录,然后在里面随便创建一个文件,再写点内容: - -```sh -vim test/hello.txt -``` - -接着我们就可以将宿主主机上的目录或文件挂载到容器的某个目录上: - -```sh -docker run -it -v ~/test:/root/test ubuntu-volume -``` - -这里用到了一个新的参数`-v`,用于指定文件挂载,这里是将我们刚刚创建好的test目录挂在到容器的/root/test路径上。 - -![image-20220703105256049](https://s2.loli.net/2022/07/03/ztEJDC4PTVAyZF2.png) - -这样我们就可以直接在容器中访问宿主主机上的文件了,当然如果我们对挂载目录中的文件进行编辑,那么相当于编辑的是宿主主机的数据: - -```sh -vim /root/test/test.txt -``` - -![image-20220703105626105](https://s2.loli.net/2022/07/03/YqUHkJiTG3Q9pAM.png) - -在宿主主机的对应目录下,可以直接访问到我们刚刚创建好的文件。 - -接着我们来将容器销毁,看看当容器不复存在时,挂载的数据时候还能保留: - -![image-20220703105847329](https://s2.loli.net/2022/07/03/B5M6Wy8AxIoqJtC.png) - -可以看到,即使我们销毁了容器,在宿主主机上的文件依然存在,并不会受到影响,这样的话,当我们下次创建新的镜像时,依然可以使用这些保存在外面的文件。 - -比如我们现在想要部署一个Nginx服务器来代理我们的前端,就可以直接将前端页面保存到宿主主机上,然后通过挂载的形式让容器中的Nginx访问,这样就算之后Nginx镜像有升级,需要重新创建,也不会影响到我们的前端页面。这里我们来测试一下,我们先将前端模板上传到服务器: - -```sh -scp Downloads/moban5676.zip 192.168.10.10:~/ -``` - -然后在服务器上解压一下: - -```sh -unzip moban5676.zip -``` - -接着我们就可以启动容器了: - -```sh -docker run -it -v ~/moban5676:/usr/share/nginx/html/ -p 80:80 -d nginx -``` - -这里我们将解压出来的目录,挂载到容器中Nginx的默认站点目录`/usr/share/nginx/html/`(由于挂在后位于顶层,会替代镜像层原有的文件),这样Nginx就直接代理了我们存放在宿主主机上的前端页面,当然别忘了把端口映射到宿主主机上,这里我们使用的镜像是官方的nginx镜像。 - -现在我们进入容器将Nginx服务启动: - -```sh -systemctl start nginx -``` - -然后通过浏览器访问看看是否代理成功: - -![image-20220703111937254](https://s2.loli.net/2022/07/03/YtgXWizh765qFxr.png) - -可以看到我们的前端页面直接被代理了,当然如果我们要编写自定义的配置,也是使用同样的方法操作即可。 - -注意如果我们在使用`-v`参数时不指定宿主主机上的目录进行挂载的话,那么就由Docker来自动创建一个目录,并且会将容器中对应路径下的内容拷贝到这个自动创建的目录中,最后挂在到容器中,这种就是由Docker管理的数据卷了(docker managed volume)我们来试试看: - -```sh -docker run -it -v /root/abc ubuntu-volume -``` - -注意这里我们仅仅指定了挂载路径,没有指定宿主主机的对应目录,继续创建: - -![image-20220703112702067](https://s2.loli.net/2022/07/03/fXCl7IRqKBvYwxj.png) - -创建后可以看到`root`目录下有一个新的`abc`目录,那么它具体是在宿主主机的哪个位置呢?这里我们依然可以使用`inspect`命令: - -```sh -docker inspect bold_banzai -``` - -![image-20220703113507320](https://s2.loli.net/2022/07/03/zFotAfeBpcRjKWN.png) - -可以看到Sorce指向的是`/var/lib`中的某个目录,我们可以进入这个目录来创建一个新的文件,进入之前记得提升一下权限,权限低了还进不去: - -![image-20220703114333446](https://s2.loli.net/2022/07/03/2bfokiMTmdGZcUE.png) - -我们来创一个新的文本文档: - -![image-20220703114429831](https://s2.loli.net/2022/07/03/yi1hSPC3bAndMXm.png) - -实际上和我们之前是一样的,也是可以在容器中看到的,当然删除容器之后,数据依然是保留的。当我们不需要使用数据卷时,可以进行删除: - -![image-20220703145011638](https://s2.loli.net/2022/07/03/f8NPDWmhLtvw3SV.png) - -当然有时候为了方便,可能并不需要直接挂载一个目录上去,仅仅是从宿主主机传递一些文件到容器中,这里我们可以使用`cp`命令来完成: - -![image-20220703115648195](https://s2.loli.net/2022/07/03/uw7S5PobAUWBtCI.png) - -这个命令支持从宿主主机复制文件到容器,或是从容器复制文件到宿主主机,使用方式类似于Linux自带的`cp`命令。 - -### 容器数据共享 - -前面我们通过挂载的形式,将宿主主机上的文件直接挂载到容器中,这样容器就可以直接访问到宿主主机上的文件了,并且在容器删除时也不会清理宿主主机上的文件。 - -我们接着来看看如何实现容器与容器之间的数据共享,实际上按照我们之前的思路,我们可以在宿主主机创建一个公共的目录,让这些需要实现共享的容器,都挂载这个公共目录: - -```sh -docker run -it -v ~/test:/root/test ubuntu-volume -``` - -![image-20220703141840532](https://s2.loli.net/2022/07/03/soxdKyY4MIXBOin.png) - -由于挂载的是宿主主机上的同一块区域,所以内容可以直接在两个容器中都能访问。当然我们也可以将另一个容器挂载的目录,直接在启动容器时指定使用此容器挂载的目录: - -```sh -docker run -it -v ~/test:/root/test --name=data_test ubuntu-volume -docker run -it --volumes-from data_test ubuntu-volume -``` - -这里使用`--volumes-from`指定另一个容器(这种用于给其他容器提供数据卷的容器,我们一般称为数据卷容器) - -![image-20220703142849845](https://s2.loli.net/2022/07/03/Uu4CjSZifv1Oyr7.png) - -可以看到,数据卷容器中挂载的内容,在当前容器中也是存在的,当然就算此时数据卷容器被删除,那么也不会影响到这边,因为这边相当于是继承了数据卷容器提供的数据卷,所以本质上还是让两个容器挂载了同样的目录实现数据共享。 - -虽然通过上面的方式,可以在容器之间实现数据传递,但是这样并不方便,可能某些时候我们仅仅是希望容器之间共享,而不希望有宿主主机这个角色直接参与到共享之中,此时我们就需要寻找一种更好的办法了。其实我们可以将数据完全放入到容器中,通过构建一个容器,来直接将容器中打包好的数据分享给其他容器,当然本质上依然是一个Docker管理的数据卷,虽然还是没有完全脱离主机,但是移植性就高得多了。 - -我们来编写一个Dockerfile: - -```dockerfile -FROM ubuntu -ADD moban5676.tar.gz /usr/share/nginx/html/ -VOLUME /usr/share/nginx/html/ -``` - -这里我们使用了一个新的指令ADD,它跟COPY命令类似,也可以复制文件到容器中,但是它可以自动对压缩文件进行解压,这里只需要将压缩好的文件填入即可,后面的VOLUME指令就像我们使用`-v`参数一样,会创建一个挂载点在容器中: - -```sh -cd test -tar -zcvf moban5676.tar.gz * -mv moban5676.tar.gz .. -cd .. -``` - -接着我们直接构建: - -```sh -docker build -t data . -``` - -![image-20220703153109650](https://s2.loli.net/2022/07/03/M7jxBUsApKtgzku.png) - -现在我们运行一个容器看看: - -![image-20220703153343461](https://s2.loli.net/2022/07/03/SUg32jlwMcY7Btp.png) - -可以看到所有的文件都自动解压出来了(除了中文文件名称乱码了之外,不过无关紧要)我们退出容器,可以看到数据卷列表中新增了我们这个容器需要使用的: - -![image-20220703153514730](https://s2.loli.net/2022/07/03/m6VCIbXyMxt3ilT.png) - -![image-20220703153542739](https://s2.loli.net/2022/07/03/KyLUic5r6oW4HDx.png) - -这个位置实际上就是数据存放在当前主机上的位置了,不过是由Docker进行管理而不是我们自定义的。现在我们就可以创建一个新的容器直接继承了: - -```sh -docker run -p 80:80 --volumes-from=data_test -d nginx -``` - -访问一下Nginx服务器,可以看到成功代理: - -![image-20220703111937254](https://s2.loli.net/2022/07/03/YtgXWizh765qFxr.png) - -这样我们就实现了将数据放在容器中进行共享,我们不需要刻意去指定宿主主机的挂载点,而是Docker自行管理,这样就算迁移主机依然可以快速部署。 - -*** - -## 容器资源管理 - -前面我们已经完成Docker的几个主要模块的学习,最后我们来看看如何对容器的资源进行管理。 - -### 容器控制操作 - -在开始之前,我们还是要先补充一些我们前面没有提到的其他容器命令。 - -首先我们的SpringBoot项目在运行是,怎么查看输出的日志信息呢? - -```sh -docker logs test -``` - -这里使用`log`命令来打印容器中的日志信息: - -![image-20220701221210083](https://s2.loli.net/2022/07/01/scNgb1uheEpiKL8.png) - -当然也可以添加`-f`参数来持续打印日志信息。 - -![image-20220701215617022](https://s2.loli.net/2022/07/01/QTDeKASvHW1rXlw.png) - -现在我们的容器已经启动了,但是我们想要进入到容器监控容器的情况怎么办呢?我们可以是`attach`命令来附加到容器启动命令的终端上: - -```sh -docker attach 容器ID/名称 -``` - -![image-20220701215829492](https://s2.loli.net/2022/07/01/QjHJsCt3DzqP6kZ.png) - -注意现在就切换为了容器内的终端,如果想要退出的话,需要先按Ctrl+P然后再按Ctrl+Q来退出终端,不能直接使用Ctrl+C来终止,这样会直接终止掉Docker中运行的Java程序的。 - -![image-20220701220018207](https://s2.loli.net/2022/07/01/XkFKtxq3Epua5ib.png) - - 退出后,容器依然是处于运行状态的。 - -我们也可以使用`exec`命令在容器中启动一个新的终端或是在容器中执行命令: - -```sh -docker exec -it test bash -``` - -`-it`和`run`命令的操作是一样的,这里执行后,会创建一个新的终端(当然原本的程序还是在正常运行)我们会在一个新的终端中进行交互: - -![image-20220701220601732](https://s2.loli.net/2022/07/01/lMc2JueBLIFz9bf.png) - -当然也可以仅仅在容器中执行一条命令: - -![image-20220701220909626](https://s2.loli.net/2022/07/01/aVvzjuEM56JmGd7.png) - -执行后会在容器中打开一个新的终端执行命令,并输出结果。 - -前面我们还学习了容器的停止操作,通过输入`stop`命令来停止容器,但是此操作并不会立即停止,而是会等待容器处理善后,那么怎么样才能强制终止容器呢?我们可以直接使用`kill`命令,相当于给进程发送SIGKILL信号,强制结束。 - -```sh -docker kill test -``` - -相比`stop`命令,`kill`就没那么温柔了。 - -有时候可能只是希望容器暂时停止运行,而不是直接终止运行,我们希望在未来的某个时间点,恢复容器的运行,此时就可以使用`pause`命令来暂停容器: - -```sh -docker pause test -``` - -暂停容器后,程序暂时停止运行,无法响应浏览器发送的请求: - -![image-20220701222537737](https://s2.loli.net/2022/07/01/1yBYnGmuXVbNFKO.png) - -![image-20220701222243900](https://s2.loli.net/2022/07/01/ovbqk7xS3LKhmOH.png) - -此时处于爱的魔力转圈圈状态,我们可以将其恢复运行,使用`unpause`命令: - -```sh -docker unpause test -``` - -恢复运行后,瞬间就响应成功了。 - -![image-20220701222323948](https://s2.loli.net/2022/07/01/g2b8mxVz1i7WJop.png) - -### 物理资源管理 - -对于一个容器,在某些情况下我们可能并不希望它占据所有的系统资源来运行,我们只希望分配一部分资源给容器,比如只分配给容器2G内存,最大只允许使用2G,不允许再占用更多的内存,此时我们就需要对容器的资源进行限制。 - -```sh -docker run -m 内存限制 --memory-swap=内存和交换分区总共的内存限制 镜像名称 -``` - -其中`-m`参数是对容器的物理内存的使用限制,而`--memory-swap`是对内存和交换分区总和的限制,它们默认都是`-1`,也就是说没有任何的限制(如果在一开始仅指定`-m`参数,那么交换内存的限制与其保持一致,内存+交换等于`-m`的两倍大小)默认情况下跟宿主主机一样,都是2G内存,现在我们可以将容器的内存限制到100M试试看,其中物理内存50M,交换内存50M,尝试启动一下SpringBoot程序: - -```sh -docker run -it -m 50M --memory-swap=100M nagocoler/springboot-test:1.0 -``` - -可以看到,上来就因为内存不足无法启动了: - -![image-20220702104653971](https://s2.loli.net/2022/07/02/MrBWZKIzgxE94Ck.png) - -当然除了对内存的限制之外,我们也可以对CPU资源进行限额,默认情况下所有的容器都可以平等地使用CPU资源,我们可以调整不同的容器的CPU权重(默认为1024),来按需分配资源,这里需要使用到`-c`选项,也可以输入全名`--cpu-share`: - -```sh -docker run -c 1024 ubuntu -docker run -c 512 ubuntu -``` - -这里容器的CPU权重比例为16比8,也就是2比1(注意多个容器时才会生效),那么当CPU资源紧张时,会按照此权重来分配资源,当然如果CPU资源并不紧张的情况下,依然是有机会使用到全部的CPU资源的。 - -这里我们使用一个压力测试工具来进行验证: - -```sh -docker run -c 1024 --name=cpu1024 -it ubuntu -docker run -c 512 --name=cpu512 -it ubuntu -``` - -接着我们分别进入容器安装`stress`压力测试工具: - -```sh -apt update && apt install -y stress -``` - -接着我们分别在两个容器中都启动压力测试工具,产生4个进程不断计算随机数的平方根: - -```sh -stress -c 4 -``` - -接着我们进入top来看看CPU状态(看完之后记得赶紧去kill掉容器,不然CPU拉满很卡的): - -![image-20220702114126128](https://s2.loli.net/2022/07/02/3dHkMWnq1ZxCyKm.png) - -可以看到权重高的容器中,分配到了更多的CPU资源,而权重低的容器中,只分配到一半的CPU资源。 - -当然我们也可以直接限制容器使用的CPU数量: - -```sh -docker run -it --cpuset-cpus=1 ubuntu -``` - -`--cpuset-cpus`选项可以直接限制在指定的CPU上运行,比如现在我们的宿主机是2核的CPU,那么就可以分0和1这两个CPU给Docker使用,限制后,只会使用CPU 1的资源了: - -![image-20220702115538699](https://s2.loli.net/2022/07/02/erovkRBi7hSOuAt.png) - -可以看到,4个进程只各自使用了25%的CPU,加在一起就是100%,也就是只能占满一个CPU的使用率。如果要分配多个CPU,则使用逗号隔开: - -```sh -docker run -it --cpuset-cpus=0,1 ubuntu -``` - -这样就会使用这两个CPU了: - -![image-20220702115818344](https://s2.loli.net/2022/07/02/rdAPYlfsgeLOZa9.png) - -当然也可以直接使用`--cpus`来限制使用的CPU资源数: - -```sh -docker run -it --cpus=1 ubuntu -``` - -![image-20220702120329140](https://s2.loli.net/2022/07/02/pUGCjlsQbEM2Ika.png) - -限制为1后,只能使用一个CPU提供的资源,所以这里加载一起只有一个CPU的资源了。当然还有更精细的`--cpu-period `和`--cpu-quota`,这里就不做介绍了。 - -最后我们来看一下对磁盘IO读写性能的限制,我们首先使用`dd`命令来测试磁盘读写速度: - -```sh -dd if=/dev/zero of=/tmp/1G bs=4k count=256000 oflag=direct -``` - -可以不用等待跑完,中途Ctrl+C结束就行: - -![image-20220702121839871](https://s2.loli.net/2022/07/02/1y3O2qbaMsxDFUJ.png) - -可以看到当前的读写速度为86.4 MB/s,我们可以通过`--device-read/write-bps`和`--device-read/write-iops`参数对其进行限制。 - -这里要先说一下区别: - -* bps:每秒读写的数据量。 -* iops:每秒IO的次数。 - -为了直观,这里我们直接使用BPS作为限制条件: - -```sh -docker run -it --device-write-bps=/dev/sda:10MB ubuntu -``` - -因为容器的文件系统是在`/dev/sda`上的,所以这我们就`/dev/sda:10MB`来限制对/dev/sda的写入速度只有10MB/s,我们来测试一下看看: - -![image-20220702122557288](https://s2.loli.net/2022/07/02/EczxDAmUCvlwT5u.png) - -可以看到现在的速度就只有10MB左右了。 - -### 容器监控 - -最后我们来看看如何对容器的运行状态进行实时监控,我们现在希望能够对容器的资源占用情况进行监控,该怎么办呢? - -我们可以使用`stats`命令来进行监控: - -```sh -docker stats -``` - -![image-20220702153236692](https://s2.loli.net/2022/07/02/hl6qw7sXuavA4pY.png) - -可以实时对容器的各项状态进行监控,包括内存使用、CPU占用、网络I/O、磁盘I/O等信息,当然如果我们限制内存的使用的话: - -```sh -docker run -d -m 200M nagocoler/springboot-test:1.0 -``` - -可以很清楚地看到限制情况: - -![image-20220702153704729](https://s2.loli.net/2022/07/02/CGc6T4iYyN7PD51.png) - -除了使用`stats`命令来实时监控情况之外,还可以使用`top`命令来查看容器中的进程: - -```sh -docker top 容器ID/名称 -``` - -![image-20220702153957780](https://s2.loli.net/2022/07/02/ytMjZXK9aivTAWD.png) - -当然也可以携带一些参数,具体的参数与Linux中`ps`命令参数一致,这里就不多做介绍了。 - -但是这样的监控是不是太原始了一点?有没有那种网页面板可以进行实时监控和管理的呢?有的。 - -我们需要单独部署一个Docker网页管理面板应用,一般比较常见的有:Portainer,我们这里可以直接通过Docker镜像的方式去部署这个应用程序,搜索一下,发现最新版维护的地址为:https://hub.docker.com/r/portainer/portainer-ce - -CE为免费的社区版本,当然也有BE商业版本,这里我们就直接安装社区版就行了,官方Linux安装教程:https://docs.portainer.io/start/install/server/docker/linux,包含一些安装前需要的准备。 - -首先我们需要创建一个数据卷供Portainer使用: - -```sh -docker volume create portainer_data -``` - -接着通过官方命令安装启动: - -```sh -docker run -d -p 8000:8000 -p 9443:9443 --name portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ce:latest -``` - -注意这里需要开放两个端口,一个是8000端口,还有一个是9443端口。 - -![image-20220702155450772](https://s2.loli.net/2022/07/02/m71ha8YWsUzPFJ4.png) - -OK,开启成功,我们可以直接登录后台面板:https://IP:9443/,这里需要HTTPS访问,浏览器可能会提示不安全,无视就行: - -![image-20220702155637366](https://s2.loli.net/2022/07/02/mukzgvnWZyrxeaM.png) - -![image-20220702155703962](https://s2.loli.net/2022/07/02/E3vy1MKPAr5OJtW.png) - -进入后就需要我们进行注册了,这里我们只需输入两次密码即可,默认用户名就是admin,填写完成后,我们就可以开始使用了: - -![image-20220702160124676](https://s2.loli.net/2022/07/02/P1JIKaMCl7guYoz.png) - -点击Get Started即可进入到管理页面,我们可以看到目前有一个本地的Docker服务器正在运行: - -![image-20220702160328972](https://s2.loli.net/2022/07/02/OUTrAEmwsNoSG8Y.png) - -我们可以点击进入,进行详细地管理,不过唯一缺点就是没中文,挺难受的,也可以使用非官方的汉化版本:https://hub.docker.com/r/6053537/portainer-ce。 - -*** - -## 单机容器编排 - -最后我们来讲解一下Docker-Compose,它能够对我们的容器进行编排。比如现在我们要在一台主机上部署很多种类型的服务,包括数据库、消息队列、SpringBoot应用程序若干,或是想要搭建一个MySQL集群,这时我们就需要创建多个容器来完成来,但是我们希望能够实现一键部署,这时该怎么办呢?我们就要用到容器编排了,让多个容器按照我们自己的编排进行部署。 - -**官方文档:**https://docs.docker.com/get-started/08_using_compose/,视频教程肯定不可能把所有的配置全部介绍完,所以如果各位小伙伴想要了解更多的配置,有更多需求的话,可以直接查阅官方文档。 - -### 快速开始 - -在Linux环境下我们需要先安装一下插件: - -```sh -sudo apt install docker-compose-plugin -``` - -接着输入`docker compose version`来验证一下是否安装成功。 - -![image-20220703163126221](https://s2.loli.net/2022/07/03/5XDiAMpgW9aqUGJ.png) - -这里我们就以部署SpringBoot项目为例,我们继续使用之前打包好的SpringBoot项目,现在我们希望部署这个SpringBoot项目的同时,部署一个MySQL服务器,一个Redis服务器,这时我们SpringBoot项目要运行的整个完整环境,先获取到对应的镜像: - -```sh -docker pull mysql/mysql-server -docker pull redis -``` - -接着,我们需要在自己的本地安装一下DockerCompose,下载地址:https://github.com/docker/compose/releases,下载自己电脑对应的版本,然后在IDEA中配置: - -![image-20220703175103531](https://s2.loli.net/2022/07/03/GmcqXEV3tsPQYd9.png) - -下载完成后,将Docker Compose可执行文件路径修改为你存放刚刚下载的可执行文件的路径,Windows直接设置路径就行,MacOS下载之后需要进行下面的操作: - -```sh -mv 下载的文件名称 docker-compose -sudo chmod 777 docker-compose -sudo mv docker-compose /usr/local/bin -``` - -配置完成后就可以正常使用了,否则会无法运行,接着我们就可以开始在IDEA中编写docker-compose.yml文件了。 - -![image-20220703180206437](https://s2.loli.net/2022/07/03/M1gcJFUfQtnEpmB.png) - -这里点击右上角的“与服务工具窗口同步”按钮,这样一会就可以在下面查看情况了。 - -我们现在就从头开始配置这个文件,现在我们要创建三个服务,一个是MySQL服务器,一个是Redis服务器,还有一个是SpringBoot服务器,需要三个容器来分别运行,首先我们先写上这三个服务: - -```yaml -version: "3.9" #首先是版本号,别乱写,这个是和Docker版本有对应的 -services: #services里面就是我们所有需要进行编排的服务了 - spring: #服务名称,随便起 - container_name: app_springboot #一会要创建的容器名称 - mysql: - container_name: app_mysql - redis: - container_name: app_redis -``` - -这样我们就配置好了一会要创建的三个服务和对应的容器名称,接着我们需要指定一下这些容器对应的镜像了,首先是我们的SpringBoot应用程序,可能我们后续还会对应用程序进行更新和修改,所以这里我们部署需要先由Dockerfile构建出镜像后,再进行部署: - -```yaml -spring: - container_name: app_springboot - build: . #build表示使用构建的镜像,.表示使用当前目录下的Dockerfile进行构建 -``` - -我们这里修改一下Dockerfile,将基础镜像修改为已经打包好JDK环境的镜像: - -```dockerfile -FROM adoptopenjdk/openjdk8 -COPY target/DockerTest-0.0.1-SNAPSHOT.jar app.jar -CMD java -jar app.jar -``` - -接着是另外两个服务,另外两个服务需要使用对应的镜像来启动容器: - -```yml -mysql: - container_name: app_mysql - image: mysql/mysql-server:latest #image表示使用对应的镜像,这里会自动从仓库下载,然后启动容器 -redis: - container_name: app_redis - image: redis:latest -``` - -还没有结束,我们还需要将SpringBoot项目的端口进行映射,最后一个简单的docker-compose配置文件就编写完成了: - -```yaml -version: "3.9" #首先是版本号,别乱写,这个是和Docker版本有对应的 -services: #services里面就是我们所有需要进行编排的服务了 - spring: #服务名称,随便起 - container_name: app_springboot #一会要创建的容器名称 - build: . - ports: - - "8080:8080" - mysql: - container_name: app_mysql - image: mysql/mysql-server:latest - redis: - container_name: app_redis - image: redis:latest -``` - -现在我们就可以直接一键部署了,我们点击下方部署按钮: - -![image-20220703182541976](https://s2.loli.net/2022/07/03/bTWZkQidsqfNc9w.png) - -![image-20220703182559020](https://s2.loli.net/2022/07/03/YHzOEhS5giBVql2.png) - -看到 Running 4/4 就表示已经部署成功了,我们现在到服务器这边来看看情况: - -![image-20220703182657205](https://s2.loli.net/2022/07/03/ZAsg3KM8r19malT.png) - -可以看到,这里确实是按照我们的配置,创建了3个容器,并且都是处于运行中,可以正常访问: - -![image-20220703182958392](https://s2.loli.net/2022/07/03/GqbV1SWMRY8jnEc.png) - -如果想要结束的话,我们只需要点击停止就行了: - -![image-20220703183240400](https://s2.loli.net/2022/07/03/ZNRB1XegVFJEaQ7.png) - -当然如果我们不再需要这套环境的话,可以直接点击下方的按钮,将整套编排给down掉,这样的话相对应的容器也会被清理的: - -![image-20220703183730693](https://s2.loli.net/2022/07/03/IOVsb3tGpqAnHk9.png) - -![image-20220703183807157](https://s2.loli.net/2022/07/03/ZWbxDKTCimdo6Mr.png) - -注意在使用docker-compose部署时,会自动创建一个新的自定义网络,并且所有的容器都是连接到这个自定义的网络里面: - -![image-20220703210431690](https://s2.loli.net/2022/07/03/NB2MfgA5GZuCSnd.png) - -这个网络默认也是使用bridge作为驱动: - -![image-20220703210531073](https://s2.loli.net/2022/07/03/jEazItdPKxuRcCQ.png) - -这样,我们就完成了一个简单的配置,去部署我们的整套环境。 - -### 部署完整项目 - -前面我们学习了使用`docker-compose`进行简单部署,但是仅仅只是简单启动了服务,我们现在来将这些服务给连起来。首先是SpringBoot项目,我们先引入依赖: - -```xml - - org.springframework.boot - spring-boot-starter-jdbc - - - - mysql - mysql-connector-java - -``` - -接着配置一下数据源,等等,我们怎么知道数据库的默认密码是多少呢?所以我们先配置一下MySQL服务: - -```yaml -mysql: - container_name: app_mysql - image: mysql/mysql-server:latest - environment: #这里我们通过环境变量配置MySQL的root账号和密码 - MYSQL_ROOT_HOST: '%' #登陆的主机,这里直接配置为'%' - MYSQL_ROOT_PASSWORD: '123456.root' #MySQL root账号的密码,别设定得太简单了 - MYSQL_DATABASE: 'study' #在启动时自动创建的数据库 - TZ: 'Asia/Shanghai' #时区 - ports: - - "3306:3306" #把端口暴露出来,当然也可以不暴露,因为默认所有容器使用的是同一个网络 -``` - -有关MySQL的详细配置请查阅:https://registry.hub.docker.com/_/mysql - -接着我们将数据源配置完成: - -```yaml -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://app_mysql:3306/study #地址直接输入容器名称,会自动进行解析,前面已经讲过了 - username: root - password: 123456.root -``` - -然后我们来写点测试的代码吧,这里我们使用JPA进行交互: - -```xml - - org.springframework.boot - spring-boot-starter-data-jpa - - - - org.projectlombok - lombok - -``` - -```java -@Data -@AllArgsConstructor -@NoArgsConstructor -@Entity -@Table(name = "db_account") -public class Account { - - @Column(name = "id") - @Id - long id; - - @Column(name = "name") - String name; - - @Column(name = "password") - String password; -} -``` - -```java -@Repository -public interface AccountRepository extends JpaRepository { - -} -``` - -```java -@RestController -public class MainController { - - @Resource - AccountRepository repository; - - @RequestMapping("/") - public String hello(){ - return "Hello World!"; - } - - @GetMapping("/get") - public Account get(@RequestParam("id") long id){ - return repository.findById(id).orElse(null); - } - - @PostMapping("/post") - public Account get(@RequestParam("id") long id, - @RequestParam("name") String name, - @RequestParam("password") String password){ - return repository.save(new Account(id, name, password)); - } -} -``` - -接着我们来修改一下配置文件: - -```yaml -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://app_mysql:3306/study - username: root - password: 123456.root - jpa: - database: mysql - show-sql: true - hibernate: - ddl-auto: update #这里自动执行DDL创建表,全程自动化,尽可能做到开箱即用 -``` - -现在代码编写完成后,我们可以将项目打包了,注意执行我们下面的打包命令,不要进行测试,因为连不上数据库: - -```sh -mvn package -DskipTests -``` - -重新生成jar包后,我们修改一下docker-compose配置,因为MySQL的启动速度比较慢,我们要一点时间等待其启动完成,如果连接不上数据库导致SpringBoot项目启动失败,我们就重启: - -```yaml -spring: #服务名称,随便起 - container_name: app_springboot #一会要创建的容器名称 - build: . - ports: - - "8080:8080" - depends_on: #这里设置一下依赖,需要等待mysql启动后才运行,但是没啥用,这个并不是等到启动完成后,而是进程建立就停止等待 - - mysql - restart: always #这里配置容器停止后自动重启 -``` - -然后我们将之前自动构建的镜像删除,等待重新构建: - -![image-20220703215050497](https://s2.loli.net/2022/07/03/frdTCPDGIuqwAWH.png) - -现在我们重新部署docker-compos吧: - -![image-20220703215133786](https://s2.loli.net/2022/07/03/Tjq8ZYiU4FewKHE.png) - -当三个服务全部为蓝色时,就表示已经正常运行了,现在我们来测试一下吧: - -![image-20220703215211999](https://s2.loli.net/2022/07/03/3TYABoDZGpK6Rjb.png) - -接着我们来试试看向数据库传入数据: - -![image-20220703215236719](https://s2.loli.net/2022/07/03/nVEURiAe7qjworl.png) - -![image-20220703215245757](https://s2.loli.net/2022/07/03/QKFDdriwJCgPbxW.png) - -可以看到响应成功,接着我们来请求一下: - -![image-20220703215329690](https://s2.loli.net/2022/07/03/uB6rYDCSbLXmOPE.png) - -这样,我们的项目和MySQL基本就是自动部署了。 - -接着我们来配置一下Redis: - -```xml - - org.springframework.boot - spring-boot-starter-data-redis - -``` - -接着配置连接信息: - -```yaml -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://app_mysql:3306/study - username: root - password: 123456.root - jpa: - database: mysql - show-sql: true - hibernate: - ddl-auto: update - redis: - host: app_redis -``` - -```java -//再加两个Redis操作进来 -@Resource -StringRedisTemplate template; - -@GetMapping("/take") -public String take(@RequestParam("key") String key){ - return template.opsForValue().get(key); -} - -@PostMapping("/put") -public String put(@RequestParam("key") String key, - @RequestParam("value") String value){ - template.opsForValue().set(key, value); - return "操作成功!"; -} -``` - -最后我们来配置一下docker-compose的配置文件: - -```yaml -redis: - container_name: app_redis - image: redis:latest - ports: - - "6379:6379" -``` - -OK,按照之前的方式,我们重新再部署一下,然后测试: - -![image-20220703220941562](https://s2.loli.net/2022/07/03/2O9ExC4YgrJsjfe.png) - -![image-20220703221002195](https://s2.loli.net/2022/07/03/1SRG8EDtx5Oqr2M.png) - -这样我们就完成整套环境+应用程序的配置了,我们在部署整个项目时,只需要使用docker-compose配置文件进行启动即可,这样就大大方便了我们的操作,实现开箱即用。甚至我们还可以专门使用一个平台来同时对多个主机进行一次性配置,大规模快速部署,而这些就留到以后的课程中再说吧。 diff --git a/青空笔记/JUC笔记/JUC笔记(一).md b/青空笔记/JUC笔记/JUC笔记(一).md deleted file mode 100644 index 34a7326..0000000 --- a/青空笔记/JUC笔记/JUC笔记(一).md +++ /dev/null @@ -1,498 +0,0 @@ -# 再谈多线程 - -> JUC相对于Java应用层的学习难度更大,**开篇推荐掌握的预备知识:**JavaSE多线程部分**(必备)**、操作系统、JVM**(推荐)**、计算机组成原理。掌握预备知识会让你的学习更加轻松,其中,JavaSE多线程部分要求必须掌握,否则无法继续学习本教程!我们不会再去重复教学JavaSE阶段的任何知识了。 -> -> 各位小伙伴一定要点击收藏按钮(收藏 = 学会) - -还记得我们在JavaSE中学习的多线程吗?让我们来回顾一下: - -在我们的操作系统之上,可以同时运行很多个进程,并且每个进程之间相互隔离互不干扰。我们的CPU会通过时间片轮转算法,为每一个进程分配时间片,并在时间片使用结束后切换下一个进程继续执行,通过这种方式来实现宏观上的多个程序同时运行。 - -由于每个进程都有一个自己的内存空间,进程之间的通信就变得非常麻烦(比如要共享某些数据)而且执行不同进程会产生上下文切换,非常耗时,那么有没有一种更好地方案呢? - -后来,线程横空出世,一个进程可以有多个线程,线程是程序执行中一个单一的顺序控制流程,现在线程才是程序执行流的最小单元,各个线程之间共享程序的内存空间(也就是所在进程的内存空间),上下文切换速度也高于进程。 - -现在有这样一个问题: - -```java -public static void main(String[] args) { - int[] arr = new int[]{3, 1, 5, 2, 4}; - //请将上面的数组按升序输出 -} -``` - -按照正常思维,我们肯定是这样: - -```java -public static void main(String[] args) { - int[] arr = new int[]{3, 1, 5, 2, 4}; - //直接排序吧 - Arrays.sort(arr); - for (int i : arr) { - System.out.println(i); - } -} -``` - -而我们学习了多线程之后,可以换个思路来实现: - -```java -public static void main(String[] args) { - int[] arr = new int[]{3, 1, 5, 2, 4}; - - for (int i : arr) { - new Thread(() -> { - try { - Thread.sleep(i * 1000); //越小的数休眠时间越短,优先被打印 - System.out.println(i); - } catch (InterruptedException e) { - e.printStackTrace(); - } - }).start(); - } -} -``` - -我们接触过的很多框架都在使用多线程,比如Tomcat服务器,所有用户的请求都是通过不同的线程来进行处理的,这样我们的网站才可以同时响应多个用户的请求,要是没有多线程,可想而知服务器的处理效率会有多低。 - -虽然多线程能够为我们解决很多问题,但是,如何才能正确地使用多线程,如何才能将多线程的资源合理使用,这都是我们需要关心的问题。 - -在Java 5的时候,新增了java.util.concurrent(JUC)包,其中包括大量用于多线程编程的工具类,目的是为了更好的支持高并发任务,让开发者进行多线程编程时减少竞争条件和死锁的问题!通过使用这些工具类,我们的程序会更加合理地使用多线程。而我们这一系列视频的主角,正是`JUC`。 - -但是我们先不着急去看这些内容,第一章,我们先来补点基础知识。 - -*** - -## 并发与并行 - -我们经常听到并发编程,那么这个并发代表的是什么意思呢?而与之相似的并行又是什么意思?它们之间有什么区别? - -比如现在一共有三个工作需要我们去完成。 - -![image-20220301213510841](https://tva1.sinaimg.cn/large/e6c9d24ely1gzupjszpjnj21bk06ujrw.jpg) - -### 顺序执行 - -顺序执行其实很好理解,就是我们依次去将这些任务完成了: - -![image-20220301213629649](https://tva1.sinaimg.cn/large/e6c9d24ely1gzupl4sldlj219s06et98.jpg) - -实际上就是我们同一时间只能处理一个任务,所以需要前一个任务完成之后,才能继续下一个任务,依次完成所有任务。 - -### 并发执行 - -并发执行也是我们同一时间只能处理一个任务,但是我们可以每个任务轮着做(时间片轮转): - -![image-20220301214032719](https://tva1.sinaimg.cn/large/e6c9d24ely1gzuppchmldj21lm078myf.jpg) - -只要我们单次处理分配的时间足够的短,在宏观看来,就是三个任务在同时进行。 - -而我们Java中的线程,正是这种机制,当我们需要同时处理上百个上千个任务时,很明显CPU的数量是不可能赶得上我们的线程数的,所以说这时就要求我们的程序有良好的并发性能,来应对同一时间大量的任务处理。学习Java并发编程,能够让我们在以后的实际场景中,知道该如何应对高并发的情况。 - -### 并行执行 - -并行执行就突破了同一时间只能处理一个任务的限制,我们同一时间可以做多个任务: - -![image-20220301214238743](https://tva1.sinaimg.cn/large/e6c9d24ely1gzuprj83gqj21hw0hqmz2.jpg) - -比如我们要进行一些排序操作,就可以用到并行计算,只需要等待所有子任务完成,最后将结果汇总即可。包括分布式计算模型MapReduce,也是采用的并行计算思路。 - -*** - -## 再谈锁机制 - -谈到锁机制,相信各位应该并不陌生了,我们在JavaSE阶段,通过使用`synchronized`关键字来实现锁,这样就能够很好地解决线程之间争抢资源的情况。那么,`synchronized`底层到底是如何实现的呢? - -我们知道,使用`synchronized`,一定是和某个对象相关联的,比如我们要对某一段代码加锁,那么我们就需要提供一个对象来作为锁本身: - -```java -public static void main(String[] args) { - synchronized (Main.class) { - //这里使用的是Main类的Class对象作为锁 - } -} -``` - -我们来看看,它变成字节码之后会用到哪些指令: - -![image-20220302111724784](https://tva1.sinaimg.cn/large/e6c9d24ely1gzvdbajqhfj229a0u0te0.jpg) - -其中最关键的就是`monitorenter`指令了,可以看到之后也有`monitorexit`与之进行匹配(注意这里有2个),`monitorenter`和`monitorexit`分别对应加锁和释放锁,在执行`monitorenter`之前需要尝试获取锁,每个对象都有一个`monitor`监视器与之对应,而这里正是去获取对象监视器的所有权,一旦`monitor`所有权被某个线程持有,那么其他线程将无法获得(管程模型的一种实现)。 - -在代码执行完成之后,我们可以看到,一共有两个`monitorexit`在等着我们,那么为什么这里会有两个呢,按理说`monitorenter`和`monitorexit`不应该一一对应吗,这里为什么要释放锁两次呢? - -首先我们来看第一个,这里在释放锁之后,会马上进入到一个goto指令,跳转到15行,而我们的15行对应的指令就是方法的返回指令,其实正常情况下只会执行第一个`monitorexit`释放锁,在释放锁之后就接着同步代码块后面的内容继续向下执行了。而第二个,其实是用来处理异常的,可以看到,它的位置是在12行,如果程序运行发生异常,那么就会执行第二个`monitorexit`,并且会继续向下通过`athrow`指令抛出异常,而不是直接跳转到15行正常运行下去。 - -![image-20220302114613847](https://tva1.sinaimg.cn/large/e6c9d24ely1gzve59lrkqj21wq0ca76u.jpg) - -实际上`synchronized`使用的锁就是存储在Java对象头中的,我们知道,对象是存放在堆内存中的,而每个对象内部,都有一部分空间用于存储对象头信息,而对象头信息中,则包含了Mark Word用于存放`hashCode`和对象的锁信息,在不同状态下,它存储的数据结构有一些不同。 - -![image-20220302203846868](https://tva1.sinaimg.cn/large/e6c9d24ely1gzvtjfgg91j21e00howh1.jpg) - -### 重量级锁 - -在JDK6之前,`synchronized`一直被称为重量级锁,`monitor`依赖于底层操作系统的Lock实现,Java的线程是映射到操作系统的原生线程上,切换成本较高。而在JDK6之后,锁的实现得到了改进。我们先从最原始的重量级锁开始: - -我们说了,每个对象都有一个monitor与之关联,在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的: - -```c++ -ObjectMonitor() { - _header = NULL; - _count = 0; //记录个数 - _waiters = 0, - _recursions = 0; - _object = NULL; - _owner = NULL; - _WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet - _WaitSetLock = 0 ; - _Responsible = NULL ; - _succ = NULL ; - _cxq = NULL ; - FreeNext = NULL ; - _EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表 - _SpinFreq = 0 ; - _SpinClock = 0 ; - OwnerIsThread = 0 ; -} -``` - -每个等待锁的线程都会被封装成ObjectWaiter对象,进入到如下机制: - -![img](https://tva1.sinaimg.cn/large/e6c9d24ely1gzvej55r7tj20dw08vjrt.jpg) - -ObjectWaiter首先会进入 Entry Set等着,当线程获取到对象的`monitor`后进入 The Owner 区域并把`monitor`中的`owner`变量设置为当前线程,同时`monitor`中的计数器`count`加1,若线程调用`wait()`方法,将释放当前持有的`monitor`,`owner`变量恢复为`null`,`count`自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放`monitor`并复位变量的值,以便其他线程进入获取对象的`monitor`。 - -虽然这样的设计思路非常合理,但是在大多数应用上,每一个线程占用同步代码块的时间并不是很长,我们完全没有必要将竞争中的线程挂起然后又唤醒,并且现代CPU基本都是多核心运行的,我们可以采用一种新的思路来实现锁。 - -在JDK1.4.2时,引入了自旋锁(JDK6之后默认开启),它不会将处于等待状态的线程挂起,而是通过无限循环的方式,不断检测是否能够获取锁,由于单个线程占用锁的时间非常短,所以说循环次数不会太多,可能很快就能够拿到锁并运行,这就是自旋锁。当然,仅仅是在等待时间非常短的情况下,自旋锁的表现会很好,但是如果等待时间太长,由于循环是需要处理器继续运算的,所以这样只会浪费处理器资源,因此自旋锁的等待时间是有限制的,默认情况下为10次,如果失败,那么会进而采用重量级锁机制。 - -![image-20220302163246988](https://tva1.sinaimg.cn/large/e6c9d24ely1gzvmffuq1hj21dm0ae75f.jpg) - -在JDK6之后,自旋锁得到了一次优化,自旋的次数限制不再是固定的,而是自适应变化的,比如在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行,那么这次自旋也是有可能成功的,所以会允许自旋更多次。当然,如果某个锁经常都自旋失败,那么有可能会不再采用自旋策略,而是直接使用重量级锁。 - -### 轻量级锁 - -> 从JDK 1.6开始,为了减少获得锁和释放锁带来的性能消耗,就引入了轻量级锁。 - -轻量级锁的目标是,在无竞争情况下,减少重量级锁产生的性能消耗(并不是为了代替重量级锁,实际上就是赌一手同一时间只有一个线程在占用资源),包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。它不像是重量级锁那样,需要向操作系统申请互斥量。它的运作机制如下: - -在即将开始执行同步代码块中的内容时,会首先检查对象的Mark Word,查看锁对象是否被其他线程占用,如果没有任何线程占用,那么会在当前线程中所处的栈帧中建立一个名为锁记录(Lock Record)的空间,用于复制并存储对象目前的Mark Word信息(官方称为Displaced Mark Word)。 - -接着,虚拟机将使用CAS操作将对象的Mark Word更新为轻量级锁状态(数据结构变为指向Lock Record的指针,指向的是当前的栈帧) - -> CAS(Compare And Swap)是一种无锁算法(我们之前在Springboot阶段已经讲解过了),它并不会为对象加锁,而是在执行的时候,看看当前数据的值是不是我们预期的那样,如果是,那就正常进行替换,如果不是,那么就替换失败。比如有两个线程都需要修改变量`i`的值,默认为10,现在一个线程要将其修改为20,另一个要修改为30,如果他们都使用CAS算法,那么并不会加锁访问`i`,而是直接尝试修改`i`的值,但是在修改时,需要确认`i`是不是10,如果是,表示其他线程还没对其进行修改,如果不是,那么说明其他线程已经将其修改,此时不能完成修改任务,修改失败。 -> -> 在CPU中,CAS操作使用的是`cmpxchg`指令,能够从最底层硬件层面得到效率的提升。 - -如果CAS操作失败了的话,那么说明可能这时有线程已经进入这个同步代码块了,这时虚拟机会再次检查对象的Mark Word,是否指向当前线程的栈帧,如果是,说明不是其他线程,而是当前线程已经有了这个对象的锁,直接放心大胆进同步代码块即可。如果不是,那确实是被其他线程占用了。 - -这时,轻量级锁一开始的想法就是错的(这时有对象在竞争资源,已经赌输了),所以说只能将锁膨胀为重量级锁,按照重量级锁的操作执行(注意锁的膨胀是不可逆的) - -![image-20220302210830272](https://tva1.sinaimg.cn/large/e6c9d24ely1gzvuebbr7ej21b20ba763.jpg) - -所以,轻量级锁 -> 失败 -> 自适应自旋锁 -> 失败 -> 重量级锁 - -解锁过程同样采用CAS算法,如果对象的MarkWord仍然指向线程的锁记录,那么就用CAS操作把对象的MarkWord和复制到栈帧中的Displaced Mark Word进行交换。如果替换失败,说明其他线程尝试过获取该锁,在释放锁的同时,需要唤醒被挂起的线程。 - -### 偏向锁 - -偏向锁相比轻量级锁更纯粹,干脆就把整个同步都消除掉,不需要再进行CAS操作了。它的出现主要是得益于人们发现某些情况下某个锁频繁地被同一个线程获取,这种情况下,我们可以对轻量级锁进一步优化。 - -偏向锁实际上就是专门为单个线程而生的,当某个线程第一次获得锁时,如果接下来都没有其他线程获取此锁,那么持有锁的线程将不再需要进行同步操作。 - -可以从之前的MarkWord结构中看到,偏向锁也会通过CAS操作记录线程的ID,如果一直都是同一个线程获取此锁,那么完全没有必要在进行额外的CAS操作。当然,如果有其他线程来抢了,那么偏向锁会根据当前状态,决定是否要恢复到未锁定或是膨胀为轻量级锁。 - -如果我们需要使用偏向锁,可以添加`-XX:+UseBiased`参数来开启。 - -所以,最终的锁等级为:未锁定 < 偏向锁 < 轻量级锁 < 重量级锁 - -值得注意的是,如果对象通过调用`hashCode()`方法计算过对象的一致性哈希值,那么它是不支持偏向锁的,会直接进入到轻量级锁状态,因为Hash是需要被保存的,而偏向锁的Mark Word数据结构,无法保存Hash值;如果对象已经是偏向锁状态,再去调用`hashCode()`方法,那么会直接将锁升级为重量级锁,并将哈希值存放在`monitor`(有预留位置保存)中。 - -![image-20220302214647735](https://tva1.sinaimg.cn/large/e6c9d24ely1gzvvi5l9jhj21cy0bwjtl.jpg) - -### 锁消除和锁粗化 - -锁消除和锁粗化都是在运行时的一些优化方案,比如我们某段代码虽然加了锁,但是在运行时根本不可能出现各个线程之间资源争夺的情况,这种情况下,完全不需要任何加锁机制,所以锁会被消除。锁粗化则是我们代码中频繁地出现互斥同步操作,比如在一个循环内部加锁,这样明显是非常消耗性能的,所以虚拟机一旦检测到这种操作,会将整个同步范围进行扩展。 - -*** - -## JMM内存模型 - -注意这里提到的内存模型和我们在JVM中介绍的内存模型不在同一个层次,JVM中的内存模型是虚拟机规范对整个内存区域的规划,而Java内存模型,是在JVM内存模型之上的抽象模型,具体实现依然是基于JVM内存模型实现的,我们会在后面介绍。 - -### Java内存模型 - -我们在`计算机组成原理`中学习过,在我们的CPU中,一般都会有高速缓存,而它的出现,是为了解决内存的速度跟不上处理器的处理速度的问题,所以CPU内部会添加一级或多级高速缓存来提高处理器的数据获取效率,但是这样也会导致一个很明显的问题,因为现在基本都是多核心处理器,每个处理器都有一个自己的高速缓存,那么又该怎么去保证每个处理器的高速缓存内容一致呢? - -![image-20220303113148313](https://tva1.sinaimg.cn/large/e6c9d24ely1gzwjckl9pfj20x60cqdgt.jpg) - -为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。 - -而Java也采用了类似的模型来实现支持多线程的内存模型: - -![image-20220303114228749](https://tva1.sinaimg.cn/large/e6c9d24ely1gzwjnodcejj20xs0ewaba.jpg) - -JMM(Java Memory Model)内存模型规定如下: - -* 所有的变量全部存储在主内存(注意这里包括下面提到的变量,指的都是会出现竞争的变量,包括成员变量、静态变量等,而局部变量这种属于线程私有,不包括在内) -* 每条线程有着自己的工作内存(可以类比CPU的高速缓存)线程对变量的所有操作,必须在工作内存中进行,不能直接操作主内存中的数据。 -* 不同线程之间的工作内存相互隔离,如果需要在线程之间传递内容,只能通过主内存完成,无法直接访问对方的工作内存。 - -也就是说,每一条线程如果要操作主内存中的数据,那么得先拷贝到自己的工作内存中,并对工作内存中数据的副本进行操作,操作完成之后,也需要从工作副本中将结果拷贝回主内存中,具体的操作就是`Save`(保存)和`Load`(加载)操作。 - -那么各位肯定会好奇,这个内存模型,结合之前JVM所讲的内容,具体是怎么实现的呢? - -* 主内存:对应堆中存放对象的实例的部分。 -* 工作内存:对应线程的虚拟机栈的部分区域,虚拟机可能会对这部分内存进行优化,将其放在CPU的寄存器或是高速缓存中。比如在访问数组时,由于数组是一段连续的内存空间,所以可以将一部分连续空间放入到CPU高速缓存中,那么之后如果我们顺序读取这个数组,那么大概率会直接缓存命中。 - -前面我们提到,在CPU中可能会遇到缓存不一致的问题,而Java中,也会遇到,比如下面这种情况: - -```java -public class Main { - private static int i = 0; - public static void main(String[] args) throws InterruptedException { - new Thread(() -> { - for (int j = 0; j < 100000; j++) i++; - System.out.println("线程1结束"); - }).start(); - new Thread(() -> { - for (int j = 0; j < 100000; j++) i++; - System.out.println("线程2结束"); - }).start(); - //等上面两个线程结束 - Thread.sleep(1000); - System.out.println(i); - } -} -``` - -可以看到这里是两个线程同时对变量`i`各自进行100000次自增操作,但是实际得到的结果并不是我们所期望的那样。 - -那么为什么会这样呢?在之前学习了JVM之后,相信各位应该已经知道,自增操作实际上并不是由一条指令完成的(注意一定不要理解为一行代码就是一个指令完成的): - -![image-20220303143131899](https://tva1.sinaimg.cn/large/e6c9d24ely1gzwojklg4fj224y0oktfi.jpg) - -包括变量`i`的获取、修改、保存,都是被拆分为一个一个的操作完成的,那么这个时候就有可能出现在修改完保存之前,另一条线程也保存了,但是当前线程是毫不知情的。 - -![image-20220303144344450](https://tva1.sinaimg.cn/large/e6c9d24ely1gzwow9xzb6j21kg0ayq54.jpg) - -所以说,我们当时在JavaSE阶段讲解这个问题的时候,是通过`synchronized`关键字添加同步代码块解决的,当然,我们后面还会讲解另外的解决方案(原子类) - -### 重排序 - -在编译或执行时,为了优化程序的执行效率,编译器或处理器常常会对指令进行重排序,有以下情况: - -1. 编译器重排序:Java编译器通过对Java代码语义的理解,根据优化规则对代码指令进行重排序。 -2. 机器指令级别的重排序:现代处理器很高级,能够自主判断和变更机器指令的执行顺序。 - -指令重排序能够在不改变结果(单线程)的情况下,优化程序的运行效率,比如: - -```java -public static void main(String[] args) { - int a = 10; - int b = 20; - System.out.println(a + b); -} -``` - -我们其实可以交换第一行和第二行: - -```java -public static void main(String[] args) { - int b = 10; - int a = 20; - System.out.println(a + b); -} -``` - -即使发生交换,但是我们程序最后的运行结果是不会变的,当然这里只通过代码的形式演示,实际上JVM在执行字节码指令时也会进行优化,可能两个指令并不会按照原有的顺序进行。 - -虽然单线程下指令重排确实可以起到一定程度的优化作用,但是在多线程下,似乎会导致一些问题: - -```java -public class Main { - private static int a = 0; - private static int b = 0; - public static void main(String[] args) { - new Thread(() -> { - if(b == 1) { - if(a == 0) { - System.out.println("A"); - }else { - System.out.println("B"); - } - } - }).start(); - new Thread(() -> { - a = 1; - b = 1; - }).start(); - } -} -``` - -上面这段代码,在正常情况下,按照我们的正常思维,是不可能输出`A`的,因为只要b等于1,那么a肯定也是1才对,因为a是在b之前完成的赋值。但是,如果进行了重排序,那么就有可能,a和b的赋值发生交换,b先被赋值为1,而恰巧这个时候,线程1开始判定b是不是1了,这时a还没来得及被赋值为1,可能线程1就已经走到打印那里去了,所以,是有可能输出`A`的。 - -### volatile关键字 - -好久好久都没有认识新的关键字了,今天我们来看一个新的关键字`volatile`,开始之前我们先介绍三个词语: - -* 原子性:其实之前讲过很多次了,就是要做什么事情要么做完,要么就不做,不存在做一半的情况。 -* 可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。 -* 有序性:即程序执行的顺序按照代码的先后顺序执行。 - -我们之前说了,如果多线程访问同一个变量,那么这个变量会被线程拷贝到自己的工作内存中进行操作,而不是直接对主内存中的变量本体进行操作,下面这个操作看起来是一个有限循环,但是是无限的: - -```java -public class Main { - private static int a = 0; - public static void main(String[] args) throws InterruptedException { - new Thread(() -> { - while (a == 0); - System.out.println("线程结束!"); - }).start(); - - Thread.sleep(1000); - System.out.println("正在修改a的值..."); - a = 1; //很明显,按照我们的逻辑来说,a的值被修改那么另一个线程将不再循环 - } -} -``` - -实际上这就是我们之前说的,虽然我们主线程中修改了a的值,但是另一个线程并不知道a的值发生了改变,所以循环中依然是使用旧值在进行判断,因此,普通变量是不具有可见性的。 - -要解决这种问题,我们第一个想到的肯定是加锁,同一时间只能有一个线程使用,这样总行了吧,确实,这样的话肯定是可以解决问题的: - -```java -public class Main { - private static int a = 0; - public static void main(String[] args) throws InterruptedException { - new Thread(() -> { - while (a == 0) { - synchronized (Main.class){} - } - System.out.println("线程结束!"); - }).start(); - - Thread.sleep(1000); - System.out.println("正在修改a的值..."); - synchronized (Main.class){ - a = 1; - } - } -} -``` - -但是,除了硬加一把锁的方案,我们也可以使用`volatile`关键字来解决,此关键字的第一个作用,就是保证变量的可见性。当写一个`volatile`变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去,并且这个写会操作会导致其他线程中的`volatile`变量缓存无效,这样,另一个线程修改了这个变时,当前线程会立即得知,并将工作内存中的变量更新为最新的版本。 - -那么我们就来试试看: - -```java -public class Main { - //添加volatile关键字 - private static volatile int a = 0; - public static void main(String[] args) throws InterruptedException { - new Thread(() -> { - while (a == 0); - System.out.println("线程结束!"); - }).start(); - - Thread.sleep(1000); - System.out.println("正在修改a的值..."); - a = 1; - } -} -``` - -结果还真的如我们所说的那样,当a发生改变时,循环立即结束。 - -当然,虽然说`volatile`能够保证可见性,但是不能保证原子性,要解决我们上面的`i++`的问题,以我们目前所学的知识,还是只能使用加锁来完成: - -```java -public class Main { - private static volatile int a = 0; - public static void main(String[] args) throws InterruptedException { - Runnable r = () -> { - for (int i = 0; i < 10000; i++) a++; - System.out.println("任务完成!"); - }; - new Thread(r).start(); - new Thread(r).start(); - - //等待线程执行完成 - Thread.sleep(1000); - System.out.println(a); - } -} -``` - -不对啊,`volatile`不是能在改变变量的时候其他线程可见吗,那为什么还是不能保证原子性呢?还是那句话,自增操作是被瓜分为了多个步骤完成的,虽然保证了可见性,但是只要手速够快,依然会出现两个线程同时写同一个值的问题(比如线程1刚刚将a的值更新为100,这时线程2可能也已经执行到更新a的值这条指令了,已经刹不住车了,所以依然会将a的值再更新为一次100) - -那要是真的遇到这种情况,那么我们不可能都去写个锁吧?后面,我们会介绍原子类来专门解决这种问题。 - -最后一个功能就是`volatile`会禁止指令重排,也就是说,如果我们操作的是一个`volatile`变量,它将不会出现重排序的情况,也就解决了我们最上面的问题。那么它是怎么解决的重排序问题呢?若用volatile修饰共享变量,在编译时,会在指令序列中插入`内存屏障`来禁止特定类型的处理器重排序 - -> 内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个: -> -> 1. 保证特定操作的顺序 -> 2. 保证某些变量的内存可见性(volatile的内存可见性,其实就是依靠这个实现的) -> -> 由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序。 -> -> ![image-20220303172519404](https://tva1.sinaimg.cn/large/e6c9d24ely1gzwtkeydk7j2194068jsd.jpg) -> -> | 屏障类型 | 指令示例 | 说明 | -> | ---------- | ------------------------ | ------------------------------------------------------------ | -> | LoadLoad | Load1;LoadLoad;Load2 | 保证Load1的读取操作在Load2及后续读取操作之前执行 | -> | StoreStore | Store1;StoreStore;Store2 | 在Store2及其后的写操作执行前,保证Store1的写操作已刷新到主内存 | -> | LoadStore | Load1;LoadStore;Store2 | 在Store2及其后的写操作执行前,保证Load1的读操作已读取结束 | -> | StoreLoad | Store1;StoreLoad;Load2 | 保证load1的写操作已刷新到主内存之后,load2及其后的读操作才能执行 | - -所以`volatile`能够保证,之前的指令一定全部执行,之后的指令一定都没有执行,并且前面语句的结果对后面的语句可见。 - -最后我们来总结一下`volatile`关键字的三个特性: - -* 保证可见性 -* 不保证原子性 -* 防止指令重排 - -在之后我们的设计模式系列视频中,还会讲解单例模式下`volatile`的运用。 - -### happens-before原则 - -经过我们前面的讲解,相信各位已经了解了JMM内存模型以及重排序等机制带来的优点和缺点,综上,JMM提出了`happens-before`(先行发生)原则,定义一些禁止编译优化的场景,来向各位程序员做一些保证,只要我们是按照原则进行编程,那么就能够保持并发编程的正确性。具体如下: - -* **程序次序规则:**同一个线程中,按照程序的顺序,前面的操作happens-before后续的任何操作。 - * 同一个线程内,代码的执行结果是有序的。其实就是,可能会发生指令重排,但是保证代码的执行结果一定是和按照顺序执行得到的一致,程序前面对某一个变量的修改一定对后续操作可见的,不可能会出现前面才把a修改为1,接着读a居然是修改前的结果,这也是程序运行最基本的要求。 -* **监视器锁规则:**对一个锁的解锁操作,happens-before后续对这个锁的加锁操作。 - * 就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果。比如前一个线程将变量`x`的值修改为了`12`并解锁,之后另一个线程拿到了这把锁,对之前线程的操作是可见的,可以得到`x`是前一个线程修改后的结果`12`(所以synchronized是有happens-before规则的) -* **volatile变量规则:**对一个volatile变量的写操作happens-before后续对这个变量的读操作。 - * 就是如果一个线程先去写一个`volatile`变量,紧接着另一个线程去读这个变量,那么这个写操作的结果一定对读的这个变量的线程可见。 -* **线程启动规则:**主线程A启动线程B,线程B中可以看到主线程启动B之前的操作。 - * 在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。 -* **线程加入规则:**如果线程A执行操作`join()`线程B并成功返回,那么线程B中的任意操作happens-before线程A`join()`操作成功返回。 -* **传递性规则:**如果A happens-before B,B happens-before C,那么A happens-before C。 - -那么我们来从happens-before原则的角度,来解释一下下面的程序结果: - -```java -public class Main { - private static int a = 0; - private static int b = 0; - public static void main(String[] args) { - a = 10; - b = a + 1; - new Thread(() -> { - if(b > 10) System.out.println(a); - }).start(); - } -} -``` - -首先我们定义以上出现的操作: - -* **A:**将变量`a`的值修改为`10` -* **B:**将变量`b`的值修改为`a + 1` -* **C:**主线程启动了一个新的线程,并在新的线程中获取`b`,进行判断,如果为`true`那么就打印`a` - -首先我们来分析,由于是同一个线程,并且**B**是一个赋值操作且读取了**A**,那么按照**程序次序规则**,A happens-before B,接着在B之后,马上执行了C,按照**线程启动规则**,在新的线程启动之前,当前线程之前的所有操作对新的线程是可见的,所以 B happens-before C,最后根据**传递性规则**,由于A happens-before B,B happens-before C,所以A happens-before C,因此在新的线程中会输出`a`修改后的结果`10`。 \ No newline at end of file diff --git a/青空笔记/JUC笔记/JUC笔记(三).md b/青空笔记/JUC笔记/JUC笔记(三).md deleted file mode 100644 index b343d60..0000000 --- a/青空笔记/JUC笔记/JUC笔记(三).md +++ /dev/null @@ -1,1614 +0,0 @@ -# 并发编程进阶 - -欢迎来到JUC学习的最后一章,王炸当然是放在最后了。 - -## 线程池 - -在我们的程序中,多多少少都会用到多线程技术,而我们以往都是使用Thread类来创建一个新的线程: - -```java -public static void main(String[] args) { - Thread t = new Thread(() -> System.out.println("Hello World!")); - t.start(); -} -``` - -利用多线程,我们的程序可以更加合理地使用CPU多核心资源,在同一时间完成更多的工作。但是,如果我们的程序频繁地创建线程,由于线程的创建和销毁也需要占用系统资源,因此这样会降低我们整个程序的性能,那么怎么做,才能更高效地使用多线程呢? - -我们其实可以将已创建的线程复用,利用池化技术,就像数据库连接池一样,我们也可以创建很多个线程,然后反复地使用这些线程,而不对它们进行销毁。 - -虽然听起来这个想法比较新颖,但是实际上线程池早已利用到各个地方,比如我们的Tomcat服务器,要在同一时间接受和处理大量的请求,那么就必须要在短时间内创建大量的线程,结束后又进行销毁,这显然会导致很大的开销,因此这种情况下使用线程池显然是更好的解决方案。 - -由于线程池可以反复利用已有线程执行多线程操作,所以它一般是有容量限制的,当所有的线程都处于工作状态时,那么新的多线程请求会被阻塞,直到有一个线程空闲出来为止,实际上这里就会用到我们之前讲解的阻塞队列。 - -所以我们可以暂时得到下面一个样子: - -![image-20220314203232154](https://tva1.sinaimg.cn/large/e6c9d24ely1h09oslzmw2j21o20i277f.jpg) - -当然,JUC提供的线程池肯定没有这么简单,接下来就让我们深入进行了解。 - -### 线程池的使用 - -我们可以直接创建一个新的线程池对象,它已经提前帮助我们实现好了线程的调度机制,我们先来看它的构造方法: - -```java -public ThreadPoolExecutor(int corePoolSize, - int maximumPoolSize, - long keepAliveTime, - TimeUnit unit, - BlockingQueue workQueue, - ThreadFactory threadFactory, - RejectedExecutionHandler handler) { - if (corePoolSize < 0 || - maximumPoolSize <= 0 || - maximumPoolSize < corePoolSize || - keepAliveTime < 0) - throw new IllegalArgumentException(); - if (workQueue == null || threadFactory == null || handler == null) - throw new NullPointerException(); - this.acc = System.getSecurityManager() == null ? - null : - AccessController.getContext(); - this.corePoolSize = corePoolSize; - this.maximumPoolSize = maximumPoolSize; - this.workQueue = workQueue; - this.keepAliveTime = unit.toNanos(keepAliveTime); - this.threadFactory = threadFactory; - this.handler = handler; -} -``` - -参数稍微有一点多,这里我们依次进行讲解: - -* corePoolSize:**核心线程池大小**,我们每向线程池提交一个多线程任务时,都会创建一个新的`核心线程`,无论是否存在其他空闲线程,直到到达核心线程池大小为止,之后会尝试复用线程资源。当然也可以在一开始就全部初始化好,调用` prestartAllCoreThreads()`即可。 -* maximumPoolSize:**最大线程池大小**,当目前线程池中所有的线程都处于运行状态,并且等待队列已满,那么就会直接尝试继续创建新的`非核心线程`运行,但是不能超过最大线程池大小。 -* keepAliveTime:**线程最大空闲时间**,当一个`非核心线程`空闲超过一定时间,会自动销毁。 -* unit:**线程最大空闲时间的时间单位** -* workQueue:**线程等待队列**,当线程池中核心线程数已满时,就会将任务暂时存到等待队列中,直到有线程资源可用为止,这里可以使用我们上一章学到的阻塞队列。 -* threadFactory:**线程创建工厂**,我们可以干涉线程池中线程的创建过程,进行自定义。 -* handler:**拒绝策略**,当等待队列和线程池都没有空间了,真的不能再来新的任务时,来了个新的多线程任务,那么只能拒绝了,这时就会根据当前设定的拒绝策略进行处理。 - -最为重要的就是线程池大小的限定了,这个也是很有学问的,合理地分配大小会使得线程池的执行效率事半功倍: - -* 首先我们可以分析一下,线程池执行任务的特性,是CPU 密集型还是 IO 密集型 - * **CPU密集型:**主要是执行计算任务,响应时间很快,CPU一直在运行,这种任务CPU的利用率很高,那么线程数应该是根据 CPU 核心数来决定,CPU 核心数 = 最大同时执行线程数,以 i5-9400F 处理器为例,CPU 核心数为 6,那么最多就能同时执行 6 个线程。 - * **IO密集型:**主要是进行 IO 操作,因为执行 IO 操作的时间比较较长,比如从硬盘读取数据之类的,CPU就得等着IO操作,很容易出现空闲状态,导致 CPU 的利用率不高,这种情况下可以适当增加线程池的大小,让更多的线程可以一起进行IO操作,一般可以配置为CPU核心数的2倍。 - -这里我们手动创建一个新的线程池看看效果: - -```java -public static void main(String[] args) throws InterruptedException { - ThreadPoolExecutor executor = - new ThreadPoolExecutor(2, 4, //2个核心线程,最大线程数为4个 - 3, TimeUnit.SECONDS, //最大空闲时间为3秒钟 - new ArrayBlockingQueue<>(2)); //这里使用容量为2的ArrayBlockingQueue队列 - - for (int i = 0; i < 6; i++) { //开始6个任务 - int finalI = i; - executor.execute(() -> { - try { - System.out.println(Thread.currentThread().getName()+" 开始执行!("+ finalI); - TimeUnit.SECONDS.sleep(1); - System.out.println(Thread.currentThread().getName()+" 已结束!("+finalI); - } catch (InterruptedException e) { - e.printStackTrace(); - } - }); - } - - TimeUnit.SECONDS.sleep(1); //看看当前线程池中的线程数量 - System.out.println("线程池中线程数量:"+executor.getPoolSize()); - TimeUnit.SECONDS.sleep(5); //等到超过空闲时间 - System.out.println("线程池中线程数量:"+executor.getPoolSize()); - - executor.shutdownNow(); //使用完线程池记得关闭,不然程序不会结束,它会取消所有等待中的任务以及试图中断正在执行的任务,关闭后,无法再提交任务,一律拒绝 - //executor.shutdown(); 同样可以关闭,但是会执行完等待队列中的任务再关闭 -} -``` - -这里我们创建了一个核心容量为2,最大容量为4,等待队列长度为2,空闲时间为3秒的线程池,现在我们向其中执行6个任务,每个任务都会进行1秒钟休眠,那么当线程池中2个核心线程都被占用时,还有4个线程就只能进入到等待队列中了,但是等待队列中只有2个容量,这时紧接着的2个任务,线程池将直接尝试创建线程,由于不大于最大容量,因此可以成功创建。最后所有线程完成之后,在等待5秒后,超过了线程池的最大空闲时间,`非核心线程`被回收了,所以线程池中只有2个线程存在。 - -那么要是等待队列设定为没有容量的SynchronousQueue呢,这个时候会发生什么? - -```java -pool-1-thread-1 开始执行!(0 -pool-1-thread-4 开始执行!(3 -pool-1-thread-3 开始执行!(2 -pool-1-thread-2 开始执行!(1 -Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task com.test.Main$$Lambda$1/1283928880@682a0b20 rejected from java.util.concurrent.ThreadPoolExecutor@3d075dc0[Running, pool size = 4, active threads = 4, queued tasks = 0, completed tasks = 0] - at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2063) - at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:830) - at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1379) - at com.test.Main.main(Main.java:15) -``` - -可以看到,前4个任务都可以正常执行,但是到第五个任务时,直接抛出了异常,这其实就是因为等待队列的容量为0,相当于没有容量,那么这个时候,就只能拒绝任务了,拒绝的操作会根据拒绝策略决定。 - -线程池的拒绝策略默认有以下几个: - -* AbortPolicy(默认):像上面一样,直接抛异常。 -* CallerRunsPolicy:直接让提交任务的线程运行这个任务,比如在主线程向线程池提交了任务,那么就直接由主线程执行。 -* DiscardOldestPolicy:丢弃队列中最近的一个任务,替换为当前任务。 -* DiscardPolicy:什么也不用做。 - -这里我们进行一下测试: - -```java -public static void main(String[] args) throws InterruptedException { - ThreadPoolExecutor executor = - new ThreadPoolExecutor(2, 4, - 3, TimeUnit.SECONDS, - new SynchronousQueue<>(), - new ThreadPoolExecutor.CallerRunsPolicy()); //使用另一个构造方法,最后一个参数传入策略,比如这里我们使用了CallerRunsPolicy策略 -``` - -CallerRunsPolicy策略是谁提交的谁自己执行,所以: - -```java -pool-1-thread-1 开始执行!(0 -pool-1-thread-2 开始执行!(1 -main 开始执行!(4 -pool-1-thread-4 开始执行!(3 -pool-1-thread-3 开始执行!(2 -pool-1-thread-3 已结束!(2 -pool-1-thread-2 已结束!(1 -pool-1-thread-1 已结束!(0 -main 已结束!(4 -pool-1-thread-4 已结束!(3 -pool-1-thread-1 开始执行!(5 -pool-1-thread-1 已结束!(5 -线程池中线程数量:4 -线程池中线程数量:2 -``` - -可以看到,当队列塞不下时,直接在主线程运行任务,运行完之后再继续向下执行。 - -我们吧策略修改为DiscardOldestPolicy试试看: - -```java -public static void main(String[] args) throws InterruptedException { - ThreadPoolExecutor executor = - new ThreadPoolExecutor(2, 4, - 3, TimeUnit.SECONDS, - new ArrayBlockingQueue<>(1), //这里设置为ArrayBlockingQueue,长度为1 - new ThreadPoolExecutor.DiscardOldestPolicy()); -``` - -它会移除等待队列中的最近的一个任务,所以可以看到有一个任务实际上是被抛弃了的: - -``` -pool-1-thread-1 开始执行!(0 -pool-1-thread-4 开始执行!(4 -pool-1-thread-3 开始执行!(3 -pool-1-thread-2 开始执行!(1 -pool-1-thread-1 已结束!(0 -pool-1-thread-4 已结束!(4 -pool-1-thread-1 开始执行!(5 -线程池中线程数量:4 -pool-1-thread-3 已结束!(3 -pool-1-thread-2 已结束!(1 -pool-1-thread-1 已结束!(5 -线程池中线程数量:2 -``` - -比较有意思的是,如果选择没有容量的SynchronousQueue作为等待队列会爆栈: - -```java -pool-1-thread-1 开始执行!(0 -pool-1-thread-3 开始执行!(2 -pool-1-thread-2 开始执行!(1 -pool-1-thread-4 开始执行!(3 -Exception in thread "main" java.lang.StackOverflowError - at java.util.concurrent.SynchronousQueue.offer(SynchronousQueue.java:912) - at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1371) - ... -pool-1-thread-1 已结束!(0 -pool-1-thread-2 已结束!(1 -pool-1-thread-4 已结束!(3 -pool-1-thread-3 已结束!(2 -``` - -这是为什么呢?我们来看看这个拒绝策略的源码: - -```java -public static class DiscardOldestPolicy implements RejectedExecutionHandler { - public DiscardOldestPolicy() { } - - public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { - if (!e.isShutdown()) { - e.getQueue().poll(); //会先执行一次出队操作,但是这对于SynchronousQueue来说毫无意义 - e.execute(r); //这里会再次调用execute方法 - } - } -} -``` - -可以看到,它会先对等待队列进行出队操作,但是由于SynchronousQueue压根没容量,所有这个操作毫无意义,然后就会递归执行`execute`方法,而进入之后,又发现没有容量不能插入,于是又重复上面的操作,这样就会无限的递归下去,最后就爆栈了。 - -当然,除了使用官方提供的4种策略之外,我们还可以使用自定义的策略: - -```java -public static void main(String[] args) throws InterruptedException { - ThreadPoolExecutor executor = - new ThreadPoolExecutor(2, 4, - 3, TimeUnit.SECONDS, - new SynchronousQueue<>(), - (r, executor1) -> { //比如这里我们也来实现一个就在当前线程执行的策略 - System.out.println("哎呀,线程池和等待队列都满了,你自己耗子尾汁吧"); - r.run(); //直接运行 - }); -``` - -接着我们来看线程创建工厂,我们可以自己决定如何创建新的线程: - -```java -public static void main(String[] args) throws InterruptedException { - ThreadPoolExecutor executor = - new ThreadPoolExecutor(2, 4, - 3, TimeUnit.SECONDS, - new SynchronousQueue<>(), - new ThreadFactory() { - int counter = 0; - @Override - public Thread newThread(Runnable r) { - return new Thread(r, "我的自定义线程-"+counter++); - } - }); - - for (int i = 0; i < 4; i++) { - executor.execute(() -> System.out.println(Thread.currentThread().getName()+" 开始执行!")); - } -} -``` - -这里传入的Runnable对象就是我们提交的任务,可以看到需要我们返回一个Thread对象,其实就是线程池创建线程的过程,而如何创建这个对象,以及它的一些属性,就都由我们来决定。 - -各位有没有想过这样一个情况,如果我们的任务在运行过程中出现异常了,那么是不是会导致线程池中的线程被销毁呢? - -```java -public static void main(String[] args) throws InterruptedException { - ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, //最大容量和核心容量锁定为1 - 0, TimeUnit.MILLISECONDS, new LinkedBlockingDeque<>()); - executor.execute(() -> { - System.out.println(Thread.currentThread().getName()); - throw new RuntimeException("我是异常!"); - }); - TimeUnit.SECONDS.sleep(1); - executor.execute(() -> { - System.out.println(Thread.currentThread().getName()); - }); -} -``` - -可以看到,出现异常之后,再次提交新的任务,执行的线程是一个新的线程了。 - -除了我们自己创建线程池之外,官方也提供了很多的线程池定义,我们可以使用`Executors`工具类来快速创建线程池: - -```java -public static void main(String[] args) throws InterruptedException { - ExecutorService executor = Executors.newFixedThreadPool(2); //直接创建一个固定容量的线程池 -} -``` - -可以看到它的内部实现为: - -```java -public static ExecutorService newFixedThreadPool(int nThreads) { - return new ThreadPoolExecutor(nThreads, nThreads, - 0L, TimeUnit.MILLISECONDS, - new LinkedBlockingQueue()); -} -``` - -这里直接将最大线程和核心线程数量设定为一样的,并且等待时间为0,因为压根不需要,并且采用的是一个无界的LinkedBlockingQueue作为等待队列。 - -使用newSingleThreadExecutor来创建只有一个线程的线程池: - -```java -public static void main(String[] args) throws InterruptedException { - ExecutorService executor = Executors.newSingleThreadExecutor(); - //创建一个只有一个线程的线程池 -} -``` - -原理如下: - -```java -public static ExecutorService newSingleThreadExecutor() { - return new FinalizableDelegatedExecutorService - (new ThreadPoolExecutor(1, 1, - 0L, TimeUnit.MILLISECONDS, - new LinkedBlockingQueue())); -} -``` - -可以看到这里并不是直接创建的一个ThreadPoolExecutor对象,而是套了一层FinalizableDelegatedExecutorService,那么这个又是什么东西呢? - -```java -static class FinalizableDelegatedExecutorService - extends DelegatedExecutorService { - FinalizableDelegatedExecutorService(ExecutorService executor) { - super(executor); - } - protected void finalize() { //在GC时,会执行finalize方法,此方法中会关闭掉线程池,释放资源 - super.shutdown(); - } -} -``` - -```java -static class DelegatedExecutorService extends AbstractExecutorService { - private final ExecutorService e; //被委派对象 - DelegatedExecutorService(ExecutorService executor) { e = executor; } //实际上所以的操作都是让委派对象执行的,有点像代理 - public void execute(Runnable command) { e.execute(command); } - public void shutdown() { e.shutdown(); } - public List shutdownNow() { return e.shutdownNow(); } -``` - -所以,下面两种写法的区别在于: - -```java -public static void main(String[] args) throws InterruptedException { - ExecutorService executor1 = Executors.newSingleThreadExecutor(); - ExecutorService executor2 = Executors.newFixedThreadPool(1); -} -``` - -前者实际上是被代理了,我们没办法直接修改前者的相关属性,显然使用前者创建只有一个线程的线程池更加专业和安全(可以防止属性被修改)一些。 - -最后我们来看`newCachedThreadPool`方法: - -```java -public static void main(String[] args) throws InterruptedException { - ExecutorService executor = Executors.newCachedThreadPool(); - //它是一个会根据需要无限制创建新线程的线程池 -} -``` - -我们来看看它的实现: - -```java -public static ExecutorService newCachedThreadPool() { - return new ThreadPoolExecutor(0, Integer.MAX_VALUE, - 60L, TimeUnit.SECONDS, - new SynchronousQueue()); -} -``` - -可以看到,核心线程数为0,那么也就是说所有的线程都是`非核心线程`,也就是说线程空闲时间超过1秒钟,一律销毁。但是它的最大容量是`Integer.MAX_VALUE`,也就是说,它可以无限制地增长下去,所以这玩意一定要慎用。 - -### 执行带返回值的任务 - -一个多线程任务不仅仅可以是void无返回值任务,比如我们现在需要执行一个任务,但是我们需要在任务执行之后得到一个结果,这个时候怎么办呢? - -这里我们就可以使用到Future了,它可以返回任务的计算结果,我们可以通过它来获取任务的结果以及任务当前是否完成: - -```java -public static void main(String[] args) throws InterruptedException, ExecutionException { - ExecutorService executor = Executors.newSingleThreadExecutor(); //直接用Executors创建,方便就完事了 - Future future = executor.submit(() -> "我是字符串!"); //使用submit提交任务,会返回一个Future对象,注意提交的对象可以是Runable也可以是Callable,这里使用的是Callable能够自定义返回值 - System.out.println(future.get()); //如果任务未完成,get会被阻塞,任务完成返回Callable执行结果返回值 - executor.shutdown(); -} -``` - -当然结果也可以一开始就定义好,然后等待Runnable执行完之后再返回: - -```java -public static void main(String[] args) throws InterruptedException, ExecutionException { - ExecutorService executor = Executors.newSingleThreadExecutor(); - Future future = executor.submit(() -> { - try { - TimeUnit.SECONDS.sleep(3); - } catch (InterruptedException e) { - e.printStackTrace(); - } - }, "我是字符串!"); - System.out.println(future.get()); - executor.shutdown(); -} -``` - -还可以通过传入FutureTask对象的方式: - -```java -public static void main(String[] args) throws ExecutionException, InterruptedException { - ExecutorService service = Executors.newSingleThreadExecutor(); - FutureTask task = new FutureTask<>(() -> "我是字符串!"); - service.submit(task); - System.out.println(task.get()); - executor.shutdown(); -} -``` - -我们可以还通过Future对象获取当前任务的一些状态: - -```java -public static void main(String[] args) throws ExecutionException, InterruptedException { - ExecutorService executor = Executors.newSingleThreadExecutor(); - Future future = executor.submit(() -> "都看到这里了,不赏UP主一个一键三连吗?"); - System.out.println(future.get()); - System.out.println("任务是否执行完成:"+future.isDone()); - System.out.println("任务是否被取消:"+future.isCancelled()); - executor.shutdown(); -} -``` - -我们来试试看在任务执行途中取消任务: - -```java -public static void main(String[] args) throws ExecutionException, InterruptedException { - ExecutorService executor = Executors.newSingleThreadExecutor(); - Future future = executor.submit(() -> { - TimeUnit.SECONDS.sleep(10); - return "这次一定!"; - }); - System.out.println(future.cancel(true)); - System.out.println(future.isCancelled()); - executor.shutdown(); -} -``` - -### 执行定时任务 - -既然线程池怎么强大,那么线程池能不能执行定时任务呢?我们之前如果需要执行一个定时任务,那么肯定会用到Timer和TimerTask,但是它只会创建一个线程处理我们的定时任务,无法实现多线程调度,并且它无法处理异常情况一旦抛出未捕获异常那么会直接终止,显然我们需要一个更加强大的定时器。 - -JDK5之后,我们可以使用ScheduledThreadPoolExecutor来提交定时任务,它继承自ThreadPoolExecutor,并且所有的构造方法都必须要求最大线程池容量为Integer.MAX_VALUE,并且都是采用的DelayedWorkQueue作为等待队列。 - -```java -public ScheduledThreadPoolExecutor(int corePoolSize) { - super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, - new DelayedWorkQueue()); -} - -public ScheduledThreadPoolExecutor(int corePoolSize, - ThreadFactory threadFactory) { - super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, - new DelayedWorkQueue(), threadFactory); -} - -public ScheduledThreadPoolExecutor(int corePoolSize, - RejectedExecutionHandler handler) { - super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, - new DelayedWorkQueue(), handler); -} - -public ScheduledThreadPoolExecutor(int corePoolSize, - ThreadFactory threadFactory, - RejectedExecutionHandler handler) { - super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, - new DelayedWorkQueue(), threadFactory, handler); -} -``` - -我们来测试一下它的方法,这个方法可以提交一个延时任务,只有到达指定时间之后才会开始: - -```java -public static void main(String[] args) throws ExecutionException, InterruptedException { - //直接设定核心线程数为1 - ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1); - //这里我们计划在3秒后执行 - executor.schedule(() -> System.out.println("HelloWorld!"), 3, TimeUnit.SECONDS); - - executor.shutdown(); -} -``` - -我们也可以像之前一样,传入一个Callable对象,用于接收返回值: - -```java -public static void main(String[] args) throws ExecutionException, InterruptedException { - ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(2); - //这里使用ScheduledFuture - ScheduledFuture future = executor.schedule(() -> "????", 3, TimeUnit.SECONDS); - System.out.println("任务剩余等待时间:"+future.getDelay(TimeUnit.MILLISECONDS) / 1000.0 + "s"); - System.out.println("任务执行结果:"+future.get()); - executor.shutdown(); -} -``` - -可以看到`schedule`方法返回了一个ScheduledFuture对象,和Future一样,它也支持返回值的获取、包括对任务的取消同时还支持获取剩余等待时间。 - -那么如果我们希望按照一定的频率不断执行任务呢? - -```java -public static void main(String[] args) throws ExecutionException, InterruptedException { - ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(2); - executor.scheduleAtFixedRate(() -> System.out.println("Hello World!"), - 3, 1, TimeUnit.SECONDS); - //三秒钟延迟开始,之后每隔一秒钟执行一次 -} -``` - -Executors也为我们预置了newScheduledThreadPool方法用于创建线程池: - -```java -public static void main(String[] args) throws ExecutionException, InterruptedException { - ScheduledExecutorService service = Executors.newScheduledThreadPool(1); - service.schedule(() -> System.out.println("Hello World!"), 1, TimeUnit.SECONDS); -} -``` - -### 线程池实现原理 - -前面我们了解了线程池的使用,那么接着我们来看看它的详细实现过程,结构稍微有点复杂,坐稳,发车了。 - -这里需要首先介绍一下ctl变量: - -```java -//这个变量比较关键,用到了原子AtomicInteger,用于同时保存线程池运行状态和线程数量(使用原子类是为了保证原子性) -//它是通过拆分32个bit位来保存数据的,前3位保存状态,后29位保存工作线程数量(那要是工作线程数量29位装不下不就GG?) -private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0)); -private static final int COUNT_BITS = Integer.SIZE - 3; //29位,线程数量位 -private static final int CAPACITY = (1 << COUNT_BITS) - 1; //计算得出最大容量(1左移29位,最大容量为2的29次方-1) - -// 所有的运行状态,注意都是只占用前3位,不会占用后29位 -// 接收新任务,并等待执行队列中的任务 -private static final int RUNNING = -1 << COUNT_BITS; //111 | 0000... (后29数量位,下同) -// 不接收新任务,但是依然等待执行队列中的任务 -private static final int SHUTDOWN = 0 << COUNT_BITS; //000 | 数量位 -// 不接收新任务,也不执行队列中的任务,并且还要中断正在执行中的任务 -private static final int STOP = 1 << COUNT_BITS; //001 | 数量位 -// 所有的任务都已结束,线程数量为0,即将完全关闭 -private static final int TIDYING = 2 << COUNT_BITS; //010 | 数量位 -// 完全关闭 -private static final int TERMINATED = 3 << COUNT_BITS; //011 | 数量位 - -// 封装和解析ctl变量的一些方法 -private static int runStateOf(int c) { return c & ~CAPACITY; } //对CAPACITY取反就是后29位全部为0,前三位全部为1,接着与c进行与运算,这样就可以只得到前三位的结果了,所以这里是取运行状态 -private static int workerCountOf(int c) { return c & CAPACITY; } -//同上,这里是为了得到后29位的结果,所以这里是取线程数量 -private static int ctlOf(int rs, int wc) { return rs | wc; } -// 比如上面的RUNNING, 0,进行与运算之后: -// 111 | 0000000000000000000000000 -``` - -![image-20220315104707467](https://tva1.sinaimg.cn/large/e6c9d24egy1h0adhrjujsj21o605gwes.jpg) - -我们先从最简单的入手,看看在调用`execute`方法之后,线程池会做些什么: - -```java -//这个就是我们指定的阻塞队列 -private final BlockingQueue workQueue; - -//再次提醒,这里没加锁!!该有什么意识不用我说了吧,所以说ctl才会使用原子类。 -public void execute(Runnable command) { - if (command == null) - throw new NullPointerException(); //如果任务为null,那执行个寂寞,所以说直接空指针 - int c = ctl.get(); //获取ctl的值,一会要读取信息的 - if (workerCountOf(c) < corePoolSize) { //判断工作线程数量是否小于核心线程数 - if (addWorker(command, true)) //如果是,那不管三七二十一,直接加新的线程执行,然后返回即可 - return; - c = ctl.get(); //如果线程添加失败(有可能其他线程也在对线程池进行操作),那就更新一下c的值 - } - if (isRunning(c) && workQueue.offer(command)) { //继续判断,如果当前线程池是运行状态,那就尝试向阻塞队列中添加一个新的等待任务 - int recheck = ctl.get(); //再次获取ctl的值 - if (! isRunning(recheck) && remove(command)) //这里是再次确认当前线程池是否关闭,如果添加等待任务后线程池关闭了,那就把刚刚加进去任务的又拿出来 - reject(command); //然后直接拒绝当前任务的提交(会根据我们的拒绝策略决定如何进行拒绝操作) - else if (workerCountOf(recheck) == 0) //如果这个时候线程池依然在运行状态,那么就检查一下当前工作线程数是否为0,如果是那就直接添加新线程执行 - addWorker(null, false); //添加一个新的非核心线程,但是注意没添加任务 - //其他情况就啥也不用做了 - } - else if (!addWorker(command, false)) //这种情况要么就是线程池没有运行,要么就是队列满了,按照我们之前的规则,核心线程数已满且队列已满,那么会直接添加新的非核心线程,但是如果已经添加到最大数量,这里肯定是会失败的 - reject(command); //确实装不下了,只能拒绝 -} -``` - -是不是感觉思路还挺清晰的,我们接着来看`addWorker`是怎么创建和执行任务的,又是一大堆代码: - -```java -private boolean addWorker(Runnable firstTask, boolean core) { - //这里给最外层循环打了个标签,方便一会的跳转操作 - retry: - for (;;) { //无限循环,老套路了,注意这里全程没加锁 - int c = ctl.get(); //获取ctl值 - int rs = runStateOf(c); //解析当前的运行状态 - - // Check if queue empty only if necessary. - if (rs >= SHUTDOWN && //判断线程池是否不是处于运行状态 - ! (rs == SHUTDOWN && //如果不是运行状态,判断线程是SHUTDOWN状态并、任务不为null、等待队列不为空,只要有其中一者不满足,直接返回false,添加失败 - firstTask == null && - ! workQueue.isEmpty())) - return false; - - for (;;) { //内层又一轮无限循环,这个循环是为了将线程计数增加,然后才可以真正地添加一个新的线程 - int wc = workerCountOf(c); //解析当前的工作线程数量 - if (wc >= CAPACITY || - wc >= (core ? corePoolSize : maximumPoolSize)) //判断一下还装得下不,如果装得下,看看是核心线程还是非核心线程,如果是核心线程,不能大于核心线程数的限制,如果是非核心线程,不能大于最大线程数限制 - return false; - if (compareAndIncrementWorkerCount(c)) //CAS自增线程计数,如果增加成功,任务完成,直接跳出继续 - break retry; //注意这里要直接跳出最外层循环,所以用到了标签(类似于goto语句) - c = ctl.get(); // 如果CAS失败,更新一下c的值 - if (runStateOf(c) != rs) //如果CAS失败的原因是因为线程池状态和一开始的不一样了,那么就重新从外层循环再来一次 - continue retry; //注意这里要直接从最外层循环继续,所以用到了标签(类似于goto语句) - // 如果是其他原因导致的CAS失败,那只可能是其他线程同时在自增,所以重新再来一次内层循环 - } - } - - //好了,线程计数自增也完了,接着就是添加新的工作线程了 - boolean workerStarted = false; //工作线程是否已启动 - boolean workerAdded = false; //工作线程是否已添加 - Worker w = null; //暂时理解为工作线程,别急,我们之后会解读Worker类 - try { - w = new Worker(firstTask); //创建新的工作线程,传入我们提交的任务 - final Thread t = w.thread; //拿到工作线程中封装的Thread对象 - if (t != null) { //如果线程不为null,那就可以安排干活了 - final ReentrantLock mainLock = this.mainLock; //又是ReentrantLock加锁环节,这里开始就是只有一个线程能进入了 - mainLock.lock(); - try { - // Recheck while holding lock. - // Back out on ThreadFactory failure or if - // shut down before lock acquired. - int rs = runStateOf(ctl.get()); //获取当前线程的运行状态 - - if (rs < SHUTDOWN || - (rs == SHUTDOWN && firstTask == null)) { //只有当前线程池是正在运行状态,或是SHUTDOWN状态且firstTask为空,那么就继续 - if (t.isAlive()) // 检查一下线程是否正在运行状态 - throw new IllegalThreadStateException(); //如果是那肯定是不能运行我们的任务的 - workers.add(w); //直接将新创建的Work丢进 workers 集合中 - int s = workers.size(); //看看当前workers的大小 - if (s > largestPoolSize) //这里是记录线程池运行以来,历史上的最多线程数 - largestPoolSize = s; - workerAdded = true; //工作线程已添加 - } - } finally { - mainLock.unlock(); //解锁 - } - if (workerAdded) { - t.start(); //启动线程 - workerStarted = true; //工作线程已启动 - } - } - } finally { - if (! workerStarted) //如果线程在上面的启动过程中失败了 - addWorkerFailed(w); //将w移出workers并将计数器-1,最后如果线程池是终止状态,会尝试加速终止线程池 - } - return workerStarted; //返回是否成功 -} -``` - -接着我们来看Worker类是如何实现的,它继承自AbstractQueuedSynchronizer,时隔两章,居然再次遇到AQS,那也就是说,它本身就是一把锁: - -```java -private final class Worker - extends AbstractQueuedSynchronizer - implements Runnable { - //用来干活的线程 - final Thread thread; - //要执行的第一个任务,构造时就确定了的 - Runnable firstTask; - //干活数量计数器,也就是这个线程完成了多少个任务 - volatile long completedTasks; - - Worker(Runnable firstTask) { - setState(-1); // 执行Task之前不让中断,将AQS的state设定为-1 - this.firstTask = firstTask; - this.thread = getThreadFactory().newThread(this); //通过预定义或是我们自定义的线程工厂创建线程 - } - - public void run() { - runWorker(this); //真正开始干活,包括当前活干完了又要等新的活来,就从这里开始,一会详细介绍 - } - - //0就是没加锁,1就是已加锁 - protected boolean isHeldExclusively() { - return getState() != 0; - } - - ... -} -``` - -最后我们来看看一个Worker到底是怎么在进行任务的: - -```java -final void runWorker(Worker w) { - Thread wt = Thread.currentThread(); //获取当前线程 - Runnable task = w.firstTask; //取出要执行的任务 - w.firstTask = null; //然后把Worker中的任务设定为null - w.unlock(); // 因为一开始为-1,这里是通过unlock操作将其修改回0,只有state大于等于0才能响应中断 - boolean completedAbruptly = true; - try { - //只要任务不为null,或是任务为空但是可以从等待队列中取出任务不为空,那么就开始执行这个任务,注意这里是无限循环,也就是说如果当前没有任务了,那么会在getTask方法中卡住,因为要从阻塞队列中等着取任务 - while (task != null || (task = getTask()) != null) { - w.lock(); //对当前Worker加锁,这里其实并不是防其他线程,而是在shutdown时保护此任务的运行 - - //由于线程池在STOP状态及以上会禁止新线程加入并且中断正在进行的线程 - if ((runStateAtLeast(ctl.get(), STOP) || //只要线程池是STOP及以上的状态,那肯定是不能开始新任务的 - (Thread.interrupted() && //线程是否已经被打上中断标记并且线程一定是STOP及以上 - runStateAtLeast(ctl.get(), STOP))) && - !wt.isInterrupted()) //再次确保线程被没有打上中断标记 - wt.interrupt(); //打中断标记 - try { - beforeExecute(wt, task); //开始之前的准备工作,这里暂时没有实现 - Throwable thrown = null; - try { - task.run(); //OK,开始执行任务 - } catch (RuntimeException x) { - thrown = x; throw x; - } catch (Error x) { - thrown = x; throw x; - } catch (Throwable x) { - thrown = x; throw new Error(x); - } finally { - afterExecute(task, thrown); //执行之后的工作,也没实现 - } - } finally { - task = null; //任务已完成,不需要了 - w.completedTasks++; //任务完成数++ - w.unlock(); //解锁 - } - } - completedAbruptly = false; - } finally { - //如果能走到这一步,那说明上面的循环肯定是跳出了,也就是说这个Worker可以丢弃了 - //所以这里会直接将 Worker 从 workers 里删除掉 - processWorkerExit(w, completedAbruptly); - } -} -``` - -那么它是怎么从阻塞队列里面获取任务的呢: - -```java -private Runnable getTask() { - boolean timedOut = false; // Did the last poll() time out? - - for (;;) { //无限循环获取 - int c = ctl.get(); //获取ctl - int rs = runStateOf(c); //解析线程池运行状态 - - // Check if queue empty only if necessary. - if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) { //判断是不是没有必要再执行等待队列中的任务了,也就是处于关闭线程池的状态了 - decrementWorkerCount(); //直接减少一个工作线程数量 - return null; //返回null,这样上面的runWorker就直接结束了,下同 - } - - int wc = workerCountOf(c); //如果线程池运行正常,那就获取当前的工作线程数量 - - // Are workers subject to culling? - boolean timed = allowCoreThreadTimeOut || wc > corePoolSize; //如果线程数大于核心线程数或是允许核心线程等待超时,那么就标记为可超时的 - - //超时或maximumPoolSize在运行期间被修改了,并且线程数大于1或等待队列为空,那也是不能获取到任务的 - if ((wc > maximumPoolSize || (timed && timedOut)) - && (wc > 1 || workQueue.isEmpty())) { - if (compareAndDecrementWorkerCount(c)) //如果CAS减少工作线程成功 - return null; //返回null - continue; //否则开下一轮循环 - } - - try { - Runnable r = timed ? - workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) : //如果可超时,那么最多等到超时时间 - workQueue.take(); //如果不可超时,那就一直等着拿任务 - if (r != null) //如果成功拿到任务,ok,返回 - return r; - timedOut = true; //否则就是超时了,下一轮循环将直接返回null - } catch (InterruptedException retry) { - timedOut = false; - } - //开下一轮循环吧 - } -} -``` - -虽然我们的源码解读越来越深,但是只要各位的思路不断,依然是可以继续往下看的。到此,有关`execute()`方法的源码解读,就先到这里。 - -接着我们来看当线程池关闭时会做什么事情: - -```java -//普通的shutdown会继续将等待队列中的线程执行完成后再关闭线程池 -public void shutdown() { - final ReentrantLock mainLock = this.mainLock; - mainLock.lock(); - try { - //判断是否有权限终止 - checkShutdownAccess(); - //CAS将线程池运行状态改为SHUTDOWN状态,还算比较温柔,详细过程看下面 - advanceRunState(SHUTDOWN); - //让闲着的线程(比如正在等新的任务)中断,但是并不会影响正在运行的线程,详细过程请看下面 - interruptIdleWorkers(); - onShutdown(); //给ScheduledThreadPoolExecutor提供的钩子方法,就是等ScheduledThreadPoolExecutor去实现的,当前类没有实现 - } finally { - mainLock.unlock(); - } - tryTerminate(); //最后尝试终止线程池 -} -``` - -```java -private void advanceRunState(int targetState) { - for (;;) { - int c = ctl.get(); //获取ctl - if (runStateAtLeast(c, targetState) || //是否大于等于指定的状态 - ctl.compareAndSet(c, ctlOf(targetState, workerCountOf(c)))) //CAS设置ctl的值 - break; //任意一个条件OK就可以结束了 - } -} -``` - -```java -private void interruptIdleWorkers(boolean onlyOne) { - final ReentrantLock mainLock = this.mainLock; - mainLock.lock(); - try { - for (Worker w : workers) { - Thread t = w.thread; //拿到Worker中的线程 - if (!t.isInterrupted() && w.tryLock()) { //先判断一下线程是不是没有被中断然后尝试加锁,但是通过前面的runWorker()源代码我们得知,开始之后是让Worker加了锁的,所以如果线程还在执行任务,那么这里肯定会false - try { - t.interrupt(); //如果走到这里,那么说明线程肯定是一个闲着的线程,直接给中断吧 - } catch (SecurityException ignore) { - } finally { - w.unlock(); //解锁 - } - } - if (onlyOne) //如果只针对一个Worker,那么就结束循环 - break; - } - } finally { - mainLock.unlock(); - } -} -``` - -而`shutdownNow()`方法也差不多,但是这里会更直接一些: - -```java -//shutdownNow开始后,不仅不允许新的任务到来,也不会再执行等待队列的线程,而且会终止正在执行的线程 -public List shutdownNow() { - List tasks; - final ReentrantLock mainLock = this.mainLock; - mainLock.lock(); - try { - checkShutdownAccess(); - //这里就是直接设定为STOP状态了,不再像shutdown那么温柔 - advanceRunState(STOP); - //直接中断所有工作线程,详细过程看下面 - interruptWorkers(); - //取出仍处于阻塞队列中的线程 - tasks = drainQueue(); - } finally { - mainLock.unlock(); - } - tryTerminate(); - return tasks; //最后返回还没开始的任务 -} -``` - -```java -private void interruptWorkers() { - final ReentrantLock mainLock = this.mainLock; - mainLock.lock(); - try { - for (Worker w : workers) //遍历所有Worker - w.interruptIfStarted(); //无差别对待,一律加中断标记 - } finally { - mainLock.unlock(); - } -} -``` - -最后的最后,我们再来看看`tryTerminate()`是怎么完完全全终止掉一个线程池的: - -```java -final void tryTerminate() { - for (;;) { //无限循环 - int c = ctl.get(); //上来先获取一下ctl值 - //只要是正在运行 或是 线程池基本上关闭了 或是 处于SHUTDOWN状态且工作队列不为空,那么这时还不能关闭线程池,返回 - if (isRunning(c) || - runStateAtLeast(c, TIDYING) || - (runStateOf(c) == SHUTDOWN && ! workQueue.isEmpty())) - return; - - //走到这里,要么处于SHUTDOWN状态且等待队列为空或是STOP状态 - if (workerCountOf(c) != 0) { // 如果工作线程数不是0,这里也会中断空闲状态下的线程 - interruptIdleWorkers(ONLY_ONE); //这里最多只中断一个空闲线程,然后返回 - return; - } - - //走到这里,工作线程也为空了,可以终止线程池了 - final ReentrantLock mainLock = this.mainLock; - mainLock.lock(); - try { - if (ctl.compareAndSet(c, ctlOf(TIDYING, 0))) { //先CAS将状态设定为TIDYING表示基本终止,正在做最后的操作 - try { - terminated(); //终止,暂时没有实现 - } finally { - ctl.set(ctlOf(TERMINATED, 0)); //最后将状态设定为TERMINATED,线程池结束了它年轻的生命 - termination.signalAll(); //如果有线程调用了awaitTermination方法,会等待当前线程池终止,到这里差不多就可以唤醒了 - } - return; //结束 - } - //注意如果CAS失败会直接进下一轮循环重新判断 - } finally { - mainLock.unlock(); - } - // else retry on failed CAS - } -} -``` - -OK,有关线程池的实现原理,我们就暂时先介绍到这里,关于更高级的定时任务线程池,这里就不做讲解了。 - -*** - -## 并发工具类 - -### 计数器锁 CountDownLatch - -多任务同步神器。它允许一个或多个线程,等待其他线程完成工作,比如现在我们有这样的一个需求: - -* 有20个计算任务,我们需要先将这些任务的结果全部计算出来,每个任务的执行时间未知 -* 当所有任务结束之后,立即整合统计最终结果 - -要实现这个需求,那么有一个很麻烦的地方,我们不知道任务到底什么时候执行完毕,那么可否将最终统计延迟一定时间进行呢?但是最终统计无论延迟多久进行,要么不能保证所有任务都完成,要么可能所有任务都完成了而这里还在等。 - -所以说,我们需要一个能够实现子任务同步的工具。 - -```java -public static void main(String[] args) throws InterruptedException { - CountDownLatch latch = new CountDownLatch(20); //创建一个初始值为10的计数器锁 - for (int i = 0; i < 20; i++) { - int finalI = i; - new Thread(() -> { - try { - Thread.sleep((long) (2000 * new Random().nextDouble())); - System.out.println("子任务"+ finalI +"执行完成!"); - } catch (InterruptedException e) { - e.printStackTrace(); - } - latch.countDown(); //每执行一次计数器都会-1 - }).start(); - } - - //开始等待所有的线程完成,当计数器为0时,恢复运行 - latch.await(); //这个操作可以同时被多个线程执行,一起等待,这里只演示了一个 - System.out.println("所有子任务都完成!任务完成!!!"); - - //注意这个计数器只能使用一次,用完只能重新创一个,没有重置的说法 -} -``` - -我们在调用`await()`方法之后,实际上就是一个等待计数器衰减为0的过程,而进行自减操作则由各个子线程来完成,当子线程完成工作后,那么就将计数器-1,所有的子线程完成之后,计数器为0,结束等待。 - -那么它是如何实现的呢?实现 原理非常简单: - -```java -public class CountDownLatch { - //同样是通过内部类实现AbstractQueuedSynchronizer - private static final class Sync extends AbstractQueuedSynchronizer { - - Sync(int count) { //这里直接使用AQS的state作为计数器(可见state能被玩出各种花样),也就是说一开始就加了count把共享锁,当线程调用countdown时,就解一层锁 - setState(count); - } - - int getCount() { - return getState(); - } - - //采用共享锁机制,因为可以被不同的线程countdown,所以实现的tryAcquireShared和tryReleaseShared - //获取这把共享锁其实就是去等待state被其他线程减到0 - protected int tryAcquireShared(int acquires) { - return (getState() == 0) ? 1 : -1; - } - - protected boolean tryReleaseShared(int releases) { - // 每次执行都会将state值-1,直到为0 - for (;;) { - int c = getState(); - if (c == 0) - return false; //如果已经是0了,那就false - int nextc = c-1; - if (compareAndSetState(c, nextc)) //CAS设置state值,失败直接下一轮循环 - return nextc == 0; //返回c-1之后,是不是0,如果是那就true,否则false,也就是说只有刚好减到0的时候才会返回true - } - } - } - - private final Sync sync; - - public CountDownLatch(int count) { - if (count < 0) throw new IllegalArgumentException("count < 0"); //count那肯定不能小于0啊 - this.sync = new Sync(count); //构造Sync对象,将count作为state初始值 - } - - //通过acquireSharedInterruptibly方法获取共享锁,但是如果state不为0,那么会被持续阻塞,详细原理下面讲 - public void await() throws InterruptedException { - sync.acquireSharedInterruptibly(1); - } - - //同上,但是会超时 - public boolean await(long timeout, TimeUnit unit) - throws InterruptedException { - return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout)); - } - - //countDown其实就是解锁一次 - public void countDown() { - sync.releaseShared(1); - } - - //获取当前的计数,也就是AQS中state的值 - public long getCount() { - return sync.getCount(); - } - - //这个就不说了 - public String toString() { - return super.toString() + "[Count = " + sync.getCount() + "]"; - } -} -``` - -在深入讲解之前,我们先大致了解一下CountDownLatch的基本实现思路: - -* 利用共享锁实现 -* 在一开始的时候就是已经上了count层锁的状态,也就是`state = count` -* `await()`就是加共享锁,但是必须`state`为`0`才能加锁成功,否则按照AQS的机制,会进入等待队列阻塞,加锁成功后结束阻塞 -* `countDown()`就是解`1`层锁,也就是靠这个方法一点一点把`state`的值减到`0` - -由于我们前面只对独占锁进行了讲解,没有对共享锁进行讲解,这里还是稍微提一下它: - -```java -public final void acquireShared(int arg) { - if (tryAcquireShared(arg) < 0) //上来就调用tryAcquireShared尝试以共享模式获取锁,小于0则失败,上面判断的是state==0返回1,否则-1,也就是说如果计数器不为0,那么这里会判断成功 - doAcquireShared(arg); //计数器不为0的时候,按照它的机制,那么会阻塞,所以我们来看看doAcquireShared中是怎么进行阻塞的 -} -``` - -```java -private void doAcquireShared(int arg) { - final Node node = addWaiter(Node.SHARED); //向等待队列中添加一个新的共享模式结点 - boolean failed = true; - try { - boolean interrupted = false; - for (;;) { //无限循环 - final Node p = node.predecessor(); //获取当前节点的前驱的结点 - if (p == head) { //如果p就是头结点,那么说明当前结点就是第一个等待节点 - int r = tryAcquireShared(arg); //会再次尝试获取共享锁 - if (r >= 0) { //要是获取成功 - setHeadAndPropagate(node, r); //那么就将当前节点设定为新的头结点,并且会继续唤醒后继节点 - p.next = null; // help GC - if (interrupted) - selfInterrupt(); - failed = false; - return; - } - } - if (shouldParkAfterFailedAcquire(p, node) && //和独占模式下一样的操作,这里不多说了 - parkAndCheckInterrupt()) - interrupted = true; - } - } finally { - if (failed) - cancelAcquire(node); //如果最后都还是没获取到,那么就cancel - } -} -//其实感觉大体上和独占模式的获取有点像,但是它多了个传播机制,会继续唤醒后续节点 -``` - -```java -private void setHeadAndPropagate(Node node, int propagate) { - Node h = head; // 取出头结点并将当前节点设定为新的头结点 - setHead(node); - - //因为一个线程成功获取到共享锁之后,有可能剩下的等待中的节点也有机会拿到共享锁 - if (propagate > 0 || h == null || h.waitStatus < 0 || - (h = head) == null || h.waitStatus < 0) { //如果propagate大于0(表示共享锁还能继续获取)或是h.waitStatus < 0,这是由于在其他线程释放共享锁时,doReleaseShared会将状态设定为PROPAGATE表示可以传播唤醒,后面会讲 - Node s = node.next; - if (s == null || s.isShared()) - doReleaseShared(); //继续唤醒下一个等待节点 - } -} -``` - -我们接着来看,它的countdown过程: - -```java -public final boolean releaseShared(int arg) { - if (tryReleaseShared(arg)) { //直接尝试释放锁,如果成功返回true(在CountDownLatch中只有state减到0的那一次,会返回true) - doReleaseShared(); //这里也会调用doReleaseShared继续唤醒后面的结点 - return true; - } - return false; //其他情况false - //不过这里countdown并没有用到这些返回值 -} -``` - -```java -private void doReleaseShared() { - for (;;) { //无限循环 - Node h = head; //获取头结点 - if (h != null && h != tail) { //如果头结点不为空且头结点不是尾结点,那么说明等待队列中存在节点 - int ws = h.waitStatus; //取一下头结点的等待状态 - if (ws == Node.SIGNAL) { //如果是SIGNAL,那么就CAS将头结点的状态设定为初始值 - if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) - continue; //失败就开下一轮循环重来 - unparkSuccessor(h); //和独占模式一样,当锁被释放,都会唤醒头结点的后继节点,doAcquireShared循环继续,如果成功,那么根据setHeadAndPropagate,又会继续调用当前方法,不断地传播下去,让后面的线程一个一个地获取到共享锁,直到不能再继续获取为止 - } - else if (ws == 0 && - !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) //如果等待状态是默认值0,那么说明后继节点已经被唤醒,直接将状态设定为PROPAGATE,它代表在后续获取资源的时候,够向后面传播 - continue; //失败就开下一轮循环重来 - } - if (h == head) // 如果头结点发生了变化,不会break,而是继续循环,否则直接break退出 - break; - } -} -``` - -可能看完之后还是有点乱,我们再来理一下: - -* 共享锁是线程共享的,同一时刻能有多个线程拥有共享锁。 -* 如果一个线程刚获取了共享锁,那么在其之后等待的线程也很有可能能够获取到锁,所以得传播下去继续尝试唤醒后面的结点,不像独占锁,独占的压根不需要考虑这些。 -* 如果一个线程刚释放了锁,不管是独占锁还是共享锁,都需要唤醒后续等待结点的线程。 - -回到CountDownLatch,再结合整个AQS共享锁的实现机制,进行一次完整的推导,看明白还是比较简单的。 - -### 循环屏障 CyclicBarrier - -好比一场游戏,我们必须等待房间内人数足够之后才能开始,并且游戏开始之后玩家需要同时进入游戏以保证公平性。 - -假如现在游戏房间内一共5人,但是游戏开始需要10人,所以我们必须等待剩下5人到来之后才能开始游戏,并且保证游戏开始时所有玩家都是同时进入,那么怎么实现这个功能呢?我们可以使用CyclicBarrier,翻译过来就是循环屏障,那么这个屏障正式为了解决这个问题而出现的。 - -```java -public static void main(String[] args) { - CyclicBarrier barrier = new CyclicBarrier(10, //创建一个初始值为10的循环屏障 - () -> System.out.println("飞机马上就要起飞了,各位特种兵请准备!")); //人等够之后执行的任务 - for (int i = 0; i < 10; i++) { - int finalI = i; - new Thread(() -> { - try { - Thread.sleep((long) (2000 * new Random().nextDouble())); - System.out.println("玩家 "+ finalI +" 进入房间进行等待... ("+barrier.getNumberWaiting()+"/10)"); - - barrier.await(); //调用await方法进行等待,直到等待的线程足够多为止 - - //开始游戏,所有玩家一起进入游戏 - System.out.println("玩家 "+ finalI +" 进入游戏!"); - } catch (InterruptedException | BrokenBarrierException e) { - e.printStackTrace(); - } - }).start(); - } -} -``` - -可以看到,循环屏障会不断阻挡线程,直到被阻挡的线程足够多时,才能一起冲破屏障,并且在冲破屏障时,我们也可以做一些其他的任务。这和人多力量大的道理是差不多的,当人足够多时方能冲破阻碍,到达美好的明天。当然,屏障由于是可循环的,所以它在被冲破后,会重新开始计数,继续阻挡后续的线程: - -```java -public static void main(String[] args) { - CyclicBarrier barrier = new CyclicBarrier(5); //创建一个初始值为5的循环屏障 - - for (int i = 0; i < 10; i++) { //创建5个线程 - int finalI = i; - new Thread(() -> { - try { - Thread.sleep((long) (2000 * new Random().nextDouble())); - System.out.println("玩家 "+ finalI +" 进入房间进行等待... ("+barrier.getNumberWaiting()+"/5)"); - - barrier.await(); //调用await方法进行等待,直到等待线程到达5才会一起继续执行 - - //人数到齐之后,可以开始游戏了 - System.out.println("玩家 "+ finalI +" 进入游戏!"); - } catch (InterruptedException | BrokenBarrierException e) { - e.printStackTrace(); - } - }).start(); - } -} -``` - -可以看到,通过使用循环屏障,我们可以对线程进行一波一波地放行,每一波都放行5个线程,当然除了自动重置之外,我们也可以调用`reset()`方法来手动进行重置操作,同样会重新计数: - -```java -public static void main(String[] args) throws InterruptedException { - CyclicBarrier barrier = new CyclicBarrier(5); //创建一个初始值为10的计数器锁 - - for (int i = 0; i < 3; i++) - new Thread(() -> { - try { - barrier.await(); - } catch (InterruptedException | BrokenBarrierException e) { - e.printStackTrace(); - } - }).start(); - - Thread.sleep(500); //等一下上面的线程开始运行 - System.out.println("当前屏障前的等待线程数:"+barrier.getNumberWaiting()); - - barrier.reset(); - System.out.println("重置后屏障前的等待线程数:"+barrier.getNumberWaiting()); -} -``` - -可以看到,在调用`reset()`之后,处于等待状态下的线程,全部被中断并且抛出BrokenBarrierException异常,循环屏障等待线程数归零。那么要是处于等待状态下的线程被中断了呢?屏障的线程等待数量会不会自动减少? - -```java -public static void main(String[] args) throws InterruptedException { - CyclicBarrier barrier = new CyclicBarrier(10); - Runnable r = () -> { - try { - barrier.await(); - } catch (InterruptedException | BrokenBarrierException e) { - e.printStackTrace(); - } - }; - Thread t = new Thread(r); - t.start(); - t.interrupt(); - new Thread(r).start(); -} -``` - -可以看到,当`await()`状态下的线程被中断,那么屏障会直接变成损坏状态,一旦屏障损坏,那么这一轮就无法再做任何等待操作了。也就是说,本来大家计划一起合力冲破屏障,结果有一个人摆烂中途退出了,那么所有人的努力都前功尽弃,这一轮的屏障也不可能再被冲破了(所以CyclicBarrier告诉我们,不要做那个害群之马,要相信你的团队,不然没有好果汁吃),只能进行`reset()`重置操作进行重置才能恢复正常。 - -乍一看,怎么感觉和之前讲的CountDownLatch有点像,好了,这里就得区分一下了,千万别搞混: - -* CountDownLatch: - 1. 它只能使用一次,是一个一次性的工具 - 2. 它是一个或多个线程用于等待其他线程完成的同步工具 -* CyclicBarrier - 1. 它可以反复使用,允许自动或手动重置计数 - 2. 它是让一定数量的线程在同一时间开始运行的同步工具 - -我们接着来看循环屏障的实现细节: - -```java -public class CyclicBarrier { - //内部类,存放broken标记,表示屏障是否损坏,损坏的屏障是无法正常工作的 - private static class Generation { - boolean broken = false; - } - - /** 内部维护一个可重入锁 */ - private final ReentrantLock lock = new ReentrantLock(); - /** 再维护一个Condition */ - private final Condition trip = lock.newCondition(); - /** 这个就是屏障的最大阻挡容量,就是构造方法传入的初始值 */ - private final int parties; - /* 在屏障破裂时做的事情 */ - private final Runnable barrierCommand; - /** 当前这一轮的Generation对象,每一轮都有一个新的,用于保存broken标记 */ - private Generation generation = new Generation(); - - //默认为最大阻挡容量,每来一个线程-1,和CountDownLatch挺像,当屏障破裂或是被重置时,都会将其重置为最大阻挡容量 - private int count; - - //构造方法 - public CyclicBarrier(int parties, Runnable barrierAction) { - if (parties <= 0) throw new IllegalArgumentException(); - this.parties = parties; - this.count = parties; - this.barrierCommand = barrierAction; - } - - public CyclicBarrier(int parties) { - this(parties, null); - } - - //开启下一轮屏障,一般屏障被冲破之后,就自动重置了,进入到下一轮 - private void nextGeneration() { - // 唤醒所有等待状态的线程 - trip.signalAll(); - // 重置count的值 - count = parties; - //创建新的Generation对象 - generation = new Generation(); - } - - //破坏当前屏障,变为损坏状态,之后就不能再使用了,除非重置 - private void breakBarrier() { - generation.broken = true; - count = parties; - trip.signalAll(); - } - - //开始等待 - public int await() throws InterruptedException, BrokenBarrierException { - try { - return dowait(false, 0L); - } catch (TimeoutException toe) { - throw new Error(toe); // 因为这里没有使用定时机制,不可能发生异常,如果发生怕是出了错误 - } - } - - //可超时的等待 - public int await(long timeout, TimeUnit unit) - throws InterruptedException, - BrokenBarrierException, - TimeoutException { - return dowait(true, unit.toNanos(timeout)); - } - - //这里就是真正的等待流程了,让我们细细道来 - private int dowait(boolean timed, long nanos) - throws InterruptedException, BrokenBarrierException, - TimeoutException { - final ReentrantLock lock = this.lock; - lock.lock(); //加锁,注意,因为多个线程都会调用await方法,因此只有一个线程能进,其他都被卡着了 - try { - final Generation g = generation; //获取当前这一轮屏障的Generation对象 - - if (g.broken) - throw new BrokenBarrierException(); //如果这一轮屏障已经损坏,那就没办法使用了 - - if (Thread.interrupted()) { //如果当前等待状态的线程被中断,那么会直接破坏掉屏障,并抛出中断异常(破坏屏障的第1种情况) - breakBarrier(); - throw new InterruptedException(); - } - - int index = --count; //如果上面都没有出现不正常,那么就走正常流程,首先count自减并赋值给index,index表示当前是等待的第几个线程 - if (index == 0) { // 如果自减之后就是0了,那么说明来的线程已经足够,可以冲破屏障了 - boolean ranAction = false; - try { - final Runnable command = barrierCommand; - if (command != null) - command.run(); //执行冲破屏障后的任务,如果这里抛异常了,那么会进finally - ranAction = true; - nextGeneration(); //一切正常,开启下一轮屏障(方法进入之后会唤醒所有等待的线程,这样所有的线程都可以同时继续运行了)然后返回0,注意最下面finally中会解锁,不然其他线程唤醒了也拿不到锁啊 - return 0; - } finally { - if (!ranAction) //如果是上面出现异常进来的,那么也会直接破坏屏障(破坏屏障的第2种情况) - breakBarrier(); - } - } - - // 能走到这里,那么说明当前等待的线程数还不够多,不足以冲破屏障 - for (;;) { //无限循环,一直等,等到能冲破屏障或是出现异常为止 - try { - if (!timed) - trip.await(); //如果不是定时的,那么就直接永久等待 - else if (nanos > 0L) - nanos = trip.awaitNanos(nanos); //否则最多等一段时间 - } catch (InterruptedException ie) { //等的时候会判断是否被中断(依然是破坏屏障的第1种情况) - if (g == generation && ! g.broken) { - breakBarrier(); - throw ie; - } else { - Thread.currentThread().interrupt(); - } - } - - if (g.broken) - throw new BrokenBarrierException(); //如果线程被唤醒之后发现屏障已经被破坏,那么直接抛异常 - - if (g != generation) //成功冲破屏障开启下一轮,那么直接返回当前是第几个等待的线程。 - return index; - - if (timed && nanos <= 0L) { //线程等待超时,也会破坏屏障(破坏屏障的第3种情况)然后抛异常 - breakBarrier(); - throw new TimeoutException(); - } - } - } finally { - lock.unlock(); //最后别忘了解锁,不然其他线程拿不到锁 - } - } - - //不多说了 - public int getParties() { - return parties; - } - - //判断是否被破坏,也是加锁访问,因为有可能这时有其他线程正在执行dowait - public boolean isBroken() { - final ReentrantLock lock = this.lock; - lock.lock(); - try { - return generation.broken; - } finally { - lock.unlock(); - } - } - - //重置操作,也要加锁 - public void reset() { - final ReentrantLock lock = this.lock; - lock.lock(); - try { - breakBarrier(); // 先破坏这一轮的线程,注意这个方法会先破坏再唤醒所有等待的线程,那么所有等待的线程会直接抛BrokenBarrierException异常(详情请看上方dowait倒数第13行) - nextGeneration(); // 开启下一轮 - } finally { - lock.unlock(); - } - } - - //获取等待线程数量,也要加锁 - public int getNumberWaiting() { - final ReentrantLock lock = this.lock; - lock.lock(); - try { - return parties - count; //最大容量 - 当前剩余容量 = 正在等待线程数 - } finally { - lock.unlock(); - } - } -} -``` - -看完了CyclicBarrier的源码之后,是不是感觉比CountDownLatch更简单一些? - -### 信号量 Semaphore - -还记得我们在《操作系统》中学习的信号量机制吗?它在解决进程之间的同步问题中起着非常大的作用。 - -> 信号量(Semaphore),有时被称为信号灯,是在多线程环境下使用的一种设施,是可以用来保证两个或多个关键代码段不被并发调用。在进入一个关键代码段之前,线程必须获取一个信号量;一旦该关键代码段完成了,那么该线程必须释放信号量。其它想进入该关键代码段的线程必须等待直到第一个线程释放信号量。 - -通过使用信号量,我们可以决定某个资源同一时间能够被访问的最大线程数,它相当于对某个资源的访问进行了流量控制。简单来说,它就是一个可以被N个线程占用的排它锁(因此也支持公平和非公平模式),我们可以在最开始设定Semaphore的许可证数量,每个线程都可以获得1个或n个许可证,当许可证耗尽或不足以供其他线程获取时,其他线程将被阻塞。 - -```java -public static void main(String[] args) throws ExecutionException, InterruptedException { - //每一个Semaphore都会在一开始获得指定的许可证数数量,也就是许可证配额 - Semaphore semaphore = new Semaphore(2); //许可证配额设定为2 - - for (int i = 0; i < 3; i++) { - new Thread(() -> { - try { - semaphore.acquire(); //申请一个许可证 - System.out.println("许可证申请成功!"); - semaphore.release(); //归还一个许可证 - } catch (InterruptedException e) { - e.printStackTrace(); - } - }).start(); - } -} -``` - -```java -public static void main(String[] args) throws ExecutionException, InterruptedException { - //每一个Semaphore都会在一开始获得指定的许可证数数量,也就是许可证配额 - Semaphore semaphore = new Semaphore(3); //许可证配额设定为3 - - for (int i = 0; i < 2; i++) - new Thread(() -> { - try { - semaphore.acquire(2); //一次性申请两个许可证 - System.out.println("许可证申请成功!"); - } catch (InterruptedException e) { - e.printStackTrace(); - } - }).start(); - -} -``` - -我们也可以通过Semaphore获取一些常规信息: - -```java -public static void main(String[] args) throws InterruptedException { - Semaphore semaphore = new Semaphore(3); //只配置一个许可证,5个线程进行争抢,不内卷还想要许可证? - for (int i = 0; i < 5; i++) - new Thread(semaphore::acquireUninterruptibly).start(); //可以以不响应中断(主要是能简写一行,方便) - Thread.sleep(500); - System.out.println("剩余许可证数量:"+semaphore.availablePermits()); - System.out.println("是否存在线程等待许可证:"+(semaphore.hasQueuedThreads() ? "是" : "否")); - System.out.println("等待许可证线程数量:"+semaphore.getQueueLength()); -} -``` - -我们可以手动回收掉所有的许可证: - -```java -public static void main(String[] args) throws InterruptedException { - Semaphore semaphore = new Semaphore(3); - new Thread(semaphore::acquireUninterruptibly).start(); - Thread.sleep(500); - System.out.println("收回剩余许可数量:"+semaphore.drainPermits()); //直接回收掉剩余的许可证 -} -``` - -这里我们模拟一下,比如现在有10个线程同时进行任务,任务要求是执行某个方法,但是这个方法最多同时只能由5个线程执行,这里我们使用信号量就非常合适。 - -### 数据交换 Exchanger - -线程之间的数据传递也可以这么简单。 - -使用Exchanger,它能够实现线程之间的数据交换: - -```java -public static void main(String[] args) throws InterruptedException { - Exchanger exchanger = new Exchanger<>(); - new Thread(() -> { - try { - System.out.println("收到主线程传递的交换数据:"+exchanger.exchange("AAAA")); - } catch (InterruptedException e) { - e.printStackTrace(); - } - }).start(); - System.out.println("收到子线程传递的交换数据:"+exchanger.exchange("BBBB")); -} -``` - -在调用`exchange`方法后,当前线程会等待其他线程调用同一个exchanger对象的`exchange`方法,当另一个线程也调用之后,方法会返回对方线程传入的参数。 - -可见功能还是比较简单的。 - -### Fork/Join框架 - -在JDK7时,出现了一个新的框架用于并行执行任务,它的目的是为了把大型任务拆分为多个小任务,最后汇总多个小任务的结果,得到整大任务的结果,并且这些小任务都是同时在进行,大大提高运算效率。Fork就是拆分,Join就是合并。 - -我们来演示一下实际的情况,比如一个算式:18x7+36x8+9x77+8x53,可以拆分为四个小任务:18x7、36x8、9x77、8x53,最后我们只需要将这四个任务的结果加起来,就是我们原本算式的结果了,有点归并排序的味道。 - -![image-20220316225312840](https://tva1.sinaimg.cn/large/e6c9d24ely1h0c43lq5kfj223e0lg42t.jpg) - -它不仅仅只是拆分任务并使用多线程,而且还可以利用工作窃取算法,提高线程的利用率。 - -> **工作窃取算法:**是指某个线程从其他队列里窃取任务来执行。一个大任务分割为若干个互不依赖的子任务,为了减少线程间的竞争,把这些子任务分别放到不同的队列里,并为每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应。但是有的线程会先把自己队列里的任务干完,而其他线程对应的队列里还有任务待处理。干完活的线程与其等着,不如帮其他线程干活,于是它就去其他线程的队列里窃取一个任务来执行。 - -![image-20220316230928072](https://tva1.sinaimg.cn/large/e6c9d24ely1h0c4kgoen9j21s00gmwis.jpg) - -现在我们来看看如何使用它,这里以计算1-1000的和为例,我们可以将其拆分为8个小段的数相加,比如1-125、126-250... ,最后再汇总即可,它也是依靠线程池来实现的: - -```java -public class Main { - public static void main(String[] args) throws InterruptedException, ExecutionException { - ForkJoinPool pool = new ForkJoinPool(); - System.out.println(pool.submit(new SubTask(1, 1000)).get()); - } - - - //继承RecursiveTask,这样才可以作为一个任务,泛型就是计算结果类型 - private static class SubTask extends RecursiveTask { - private final int start; //比如我们要计算一个范围内所有数的和,那么就需要限定一下范围,这里用了两个int存放 - private final int end; - - public SubTask(int start, int end) { - this.start = start; - this.end = end; - } - - @Override - protected Integer compute() { - if(end - start > 125) { //每个任务最多计算125个数的和,如果大于继续拆分,小于就可以开始算了 - SubTask subTask1 = new SubTask(start, (end + start) / 2); - subTask1.fork(); //会继续划分子任务执行 - SubTask subTask2 = new SubTask((end + start) / 2 + 1, end); - subTask2.fork(); //会继续划分子任务执行 - return subTask1.join() + subTask2.join(); //越玩越有递归那味了 - } else { - System.out.println(Thread.currentThread().getName()+" 开始计算 "+start+"-"+end+" 的值!"); - int res = 0; - for (int i = start; i <= end; i++) { - res += i; - } - return res; //返回的结果会作为join的结果 - } - } - } -} -``` - -``` -ForkJoinPool-1-worker-2 开始计算 1-125 的值! -ForkJoinPool-1-worker-2 开始计算 126-250 的值! -ForkJoinPool-1-worker-0 开始计算 376-500 的值! -ForkJoinPool-1-worker-6 开始计算 751-875 的值! -ForkJoinPool-1-worker-3 开始计算 626-750 的值! -ForkJoinPool-1-worker-5 开始计算 501-625 的值! -ForkJoinPool-1-worker-4 开始计算 251-375 的值! -ForkJoinPool-1-worker-7 开始计算 876-1000 的值! -500500 -``` - -可以看到,结果非常正确,但是整个计算任务实际上是拆分为了8个子任务同时完成的,结合多线程,原本的单线程任务,在多线程的加持下速度成倍提升。 - -包括Arrays工具类提供的并行排序也是利用了ForkJoinPool来实现: - -```java -public static void parallelSort(byte[] a) { - int n = a.length, p, g; - if (n <= MIN_ARRAY_SORT_GRAN || - (p = ForkJoinPool.getCommonPoolParallelism()) == 1) - DualPivotQuicksort.sort(a, 0, n - 1); - else - new ArraysParallelSortHelpers.FJByte.Sorter - (null, a, new byte[n], 0, n, 0, - ((g = n / (p << 2)) <= MIN_ARRAY_SORT_GRAN) ? - MIN_ARRAY_SORT_GRAN : g).invoke(); -} -``` - -并行排序的性能在多核心CPU环境下,肯定是优于普通排序的,并且排序规模越大优势越显著。 - -至此,并发编程篇完结。 diff --git a/青空笔记/JUC笔记/JUC笔记(二).md b/青空笔记/JUC笔记/JUC笔记(二).md deleted file mode 100644 index da12940..0000000 --- a/青空笔记/JUC笔记/JUC笔记(二).md +++ /dev/null @@ -1,2225 +0,0 @@ -# 多线程编程核心 - -在前面,我们了解了多线程的底层运作机制,我们终于知道,原来多线程环境下存在着如此之多的问题。在JDK5之前,我们只能选择`synchronized`关键字来实现锁,而JDK5之后,由于`volatile`关键字得到了升级(具体功能就是上一章所描述的),所以并发框架包便出现了,相比传统的`synchronized`关键字,我们对于锁的实现,有了更多的选择。 - -> Doug Lea — JUC并发包的作者 -> -> 如果IT的历史,是以人为主体串接起来的话,那么肯定少不了Doug Lea。这个鼻梁挂着眼镜,留着德王威廉二世的胡子,脸上永远挂着谦逊腼腆笑容,服务于纽约州立大学Oswego分校计算机科学系的老大爷。 -> -> 说他是这个世界上对Java影响力最大的一个人,一点也不为过。因为两次Java历史上的大变革,他都间接或直接的扮演了举足轻重的角色。2004年所推出的Tiger。Tiger广纳了15项JSRs(Java Specification Requests)的语法及标准,其中一项便是JSR-166。JSR-166是来自于Doug编写的util.concurrent包。 - -那么,从这章开始,就让我们来感受一下,JUC为我们带来了什么。 - -*** - -## 锁框架 - -在JDK 5之后,并发包中新增了Lock接口(以及相关实现类)用来实现锁功能,Lock接口提供了与synchronized关键字类似的同步功能,但需要在使用时手动获取锁和释放锁。 - -### Lock和Condition接口 - -使用并发包中的锁和我们传统的`synchronized`锁不太一样,这里的锁我们可以认为是一把真正意义上的锁,每个锁都是一个对应的锁对象,我只需要向锁对象获取锁或是释放锁即可。我们首先来看看,此接口中定义了什么: - -```java -public interface Lock { - //获取锁,拿不到锁会阻塞,等待其他线程释放锁,获取到锁后返回 - void lock(); - //同上,但是等待过程中会响应中断 - void lockInterruptibly() throws InterruptedException; - //尝试获取锁,但是不会阻塞,如果能获取到会返回true,不能返回false - boolean tryLock(); - //尝试获取锁,但是可以限定超时时间,如果超出时间还没拿到锁返回false,否则返回true,可以响应中断 - boolean tryLock(long time, TimeUnit unit) throws InterruptedException; - //释放锁 - void unlock(); - //暂时可以理解为替代传统的Object的wait()、notify()等操作的工具 - Condition newCondition(); -} -``` - -这里我们可以演示一下,如何使用Lock类来进行加锁和释放锁操作: - -```java -public class Main { - private static int i = 0; - public static void main(String[] args) throws InterruptedException { - Lock testLock = new ReentrantLock(); //可重入锁ReentrantLock类是Lock类的一个实现,我们后面会进行介绍 - Runnable action = () -> { - for (int j = 0; j < 100000; j++) { //还是以自增操作为例 - testLock.lock(); //加锁,加锁成功后其他线程如果也要获取锁,会阻塞,等待当前线程释放 - i++; - testLock.unlock(); //解锁,释放锁之后其他线程就可以获取这把锁了(注意在这之前一定得加锁,不然报错) - } - }; - new Thread(action).start(); - new Thread(action).start(); - Thread.sleep(1000); //等上面两个线程跑完 - System.out.println(i); - } -} -``` - -可以看到,和我们之前使用`synchronized`相比,我们这里是真正在操作一个"锁"对象,当我们需要加锁时,只需要调用`lock()`方法,而需要释放锁时,只需要调用`unlock()`方法。程序运行的最终结果和使用`synchronized`锁是一样的。 - -那么,我们如何像传统的加锁那样,调用对象的`wait()`和`notify()`方法呢,并发包提供了Condition接口: - -```java -public interface Condition { - //与调用锁对象的wait方法一样,会进入到等待状态,但是这里需要调用Condition的signal或signalAll方法进行唤醒(感觉就是和普通对象的wait和notify是对应的)同时,等待状态下是可以响应中断的 - void await() throws InterruptedException; - //同上,但不响应中断(看名字都能猜到) - void awaitUninterruptibly(); - //等待指定时间,如果在指定时间(纳秒)内被唤醒,会返回剩余时间,如果超时,会返回0或负数,可以响应中断 - long awaitNanos(long nanosTimeout) throws InterruptedException; - //等待指定时间(可以指定时间单位),如果等待时间内被唤醒,返回true,否则返回false,可以响应中断 - boolean await(long time, TimeUnit unit) throws InterruptedException; - //可以指定一个明确的时间点,如果在时间点之前被唤醒,返回true,否则返回false,可以响应中断 - boolean awaitUntil(Date deadline) throws InterruptedException; - //唤醒一个处于等待状态的线程,注意还得获得锁才能接着运行 - void signal(); - //同上,但是是唤醒所有等待线程 - void signalAll(); -} -``` - -这里我们通过一个简单的例子来演示一下: - -```java -public static void main(String[] args) throws InterruptedException { - Lock testLock = new ReentrantLock(); - Condition condition = testLock.newCondition(); - new Thread(() -> { - testLock.lock(); //和synchronized一样,必须持有锁的情况下才能使用await - System.out.println("线程1进入等待状态!"); - try { - condition.await(); //进入等待状态 - } catch (InterruptedException e) { - e.printStackTrace(); - } - System.out.println("线程1等待结束!"); - testLock.unlock(); - }).start(); - Thread.sleep(100); //防止线程2先跑 - new Thread(() -> { - testLock.lock(); - System.out.println("线程2开始唤醒其他等待线程"); - condition.signal(); //唤醒线程1,但是此时线程1还必须要拿到锁才能继续运行 - System.out.println("线程2结束"); - testLock.unlock(); //这里释放锁之后,线程1就可以拿到锁继续运行了 - }).start(); -} -``` - -可以发现,Condition对象使用方法和传统的对象使用差别不是很大。 - -**思考:**下面这种情况跟上面有什么不同? - -```java -public static void main(String[] args) throws InterruptedException { - Lock testLock = new ReentrantLock(); - new Thread(() -> { - testLock.lock(); - System.out.println("线程1进入等待状态!"); - try { - testLock.newCondition().await(); - } catch (InterruptedException e) { - e.printStackTrace(); - } - System.out.println("线程1等待结束!"); - testLock.unlock(); - }).start(); - Thread.sleep(100); - new Thread(() -> { - testLock.lock(); - System.out.println("线程2开始唤醒其他等待线程"); - testLock.newCondition().signal(); - System.out.println("线程2结束"); - testLock.unlock(); - }).start(); -} -``` - -通过分析可以得到,在调用`newCondition()`后,会生成一个新的Condition对象,并且同一把锁内是可以存在多个Condition对象的(实际上原始的锁机制等待队列只能有一个,而这里可以创建很多个Condition来实现多等待队列),而上面的例子中,实际上使用的是不同的Condition对象,只有对同一个Condition对象进行等待和唤醒操作才会有效,而不同的Condition对象是分开计算的。 - -最后我们再来讲解一下时间单位,这是一个枚举类,也是位于`java.util.concurrent`包下: - -```java -public enum TimeUnit { - /** - * Time unit representing one thousandth of a microsecond - */ - NANOSECONDS { - public long toNanos(long d) { return d; } - public long toMicros(long d) { return d/(C1/C0); } - public long toMillis(long d) { return d/(C2/C0); } - public long toSeconds(long d) { return d/(C3/C0); } - public long toMinutes(long d) { return d/(C4/C0); } - public long toHours(long d) { return d/(C5/C0); } - public long toDays(long d) { return d/(C6/C0); } - public long convert(long d, TimeUnit u) { return u.toNanos(d); } - int excessNanos(long d, long m) { return (int)(d - (m*C2)); } - }, - //.... -``` - -可以看到时间单位有很多的,比如`DAY`、`SECONDS`、`MINUTES`等,我们可以直接将其作为时间单位,比如我们要让一个线程等待3秒钟,可以像下面这样编写: - -```java -public static void main(String[] args) throws InterruptedException { - Lock testLock = new ReentrantLock(); - new Thread(() -> { - testLock.lock(); - try { - System.out.println("等待是否未超时:"+testLock.newCondition().await(1, TimeUnit.SECONDS)); - } catch (InterruptedException e) { - e.printStackTrace(); - } - testLock.unlock(); - }).start(); -} -``` - -当然,Lock类的tryLock方法也是支持使用时间单位的,各位可以自行进行测试。TimeUnit除了可以作为时间单位表示以外,还可以在不同单位之间相互转换: - -```java -public static void main(String[] args) throws InterruptedException { - System.out.println("60秒 = "+TimeUnit.SECONDS.toMinutes(60) +"分钟"); - System.out.println("365天 = "+TimeUnit.DAYS.toSeconds(365) +" 秒"); -} -``` - -也可以更加便捷地使用对象的`wait()`方法: - -```java -public static void main(String[] args) throws InterruptedException { - synchronized (Main.class) { - System.out.println("开始等待"); - TimeUnit.SECONDS.timedWait(Main.class, 3); //直接等待3秒 - System.out.println("等待结束"); - } -} -``` - -我们也可以直接使用它来进行休眠操作: - -```java -public static void main(String[] args) throws InterruptedException { - TimeUnit.SECONDS.sleep(1); //休眠1秒钟 -} -``` - -### 可重入锁 - -前面,我们讲解了锁框架的两个核心接口,那么我们接着来看看锁接口的具体实现类,我们前面用到了ReentrantLock,它其实是锁的一种,叫做可重入锁,那么这个可重入代表的是什么意思呢?简单来说,就是同一个线程,可以反复进行加锁操作: - -```java -public static void main(String[] args) throws InterruptedException { - ReentrantLock lock = new ReentrantLock(); - lock.lock(); - lock.lock(); //连续加锁2次 - new Thread(() -> { - System.out.println("线程2想要获取锁"); - lock.lock(); - System.out.println("线程2成功获取到锁"); - }).start(); - lock.unlock(); - System.out.println("线程1释放了一次锁"); - TimeUnit.SECONDS.sleep(1); - lock.unlock(); - System.out.println("线程1再次释放了一次锁"); //释放两次后其他线程才能加锁 -} -``` - -可以看到,主线程连续进行了两次加锁操作(此操作是不会被阻塞的),在当前线程持有锁的情况下继续加锁不会被阻塞,并且,加锁几次,就必须要解锁几次,否则此线程依旧持有锁。我们可以使用`getHoldCount()`方法查看当前线程的加锁次数: - -```java -public static void main(String[] args) throws InterruptedException { - ReentrantLock lock = new ReentrantLock(); - lock.lock(); - lock.lock(); - System.out.println("当前加锁次数:"+lock.getHoldCount()+",是否被锁:"+lock.isLocked()); - TimeUnit.SECONDS.sleep(1); - lock.unlock(); - System.out.println("当前加锁次数:"+lock.getHoldCount()+",是否被锁:"+lock.isLocked()); - TimeUnit.SECONDS.sleep(1); - lock.unlock(); - System.out.println("当前加锁次数:"+lock.getHoldCount()+",是否被锁:"+lock.isLocked()); -} -``` - -可以看到,当锁不再被任何线程持有时,值为`0`,并且通过`isLocked()`方法查询结果为`false`。 - -实际上,如果存在线程持有当前的锁,那么其他线程在获取锁时,是会暂时进入到等待队列的,我们可以通过`getQueueLength()`方法获取等待中线程数量的预估值: - -```java -public static void main(String[] args) throws InterruptedException { - ReentrantLock lock = new ReentrantLock(); - lock.lock(); - Thread t1 = new Thread(lock::lock), t2 = new Thread(lock::lock);; - t1.start(); - t2.start(); - TimeUnit.SECONDS.sleep(1); - System.out.println("当前等待锁释放的线程数:"+lock.getQueueLength()); - System.out.println("线程1是否在等待队列中:"+lock.hasQueuedThread(t1)); - System.out.println("线程2是否在等待队列中:"+lock.hasQueuedThread(t2)); - System.out.println("当前线程是否在等待队列中:"+lock.hasQueuedThread(Thread.currentThread())); -} -``` - -我们可以通过`hasQueuedThread()`方法来判断某个线程是否正在等待获取锁状态。 - -同样的,Condition也可以进行判断: - -```java -public static void main(String[] args) throws InterruptedException { - ReentrantLock lock = new ReentrantLock(); - Condition condition = lock.newCondition(); - new Thread(() -> { - lock.lock(); - try { - condition.await(); - } catch (InterruptedException e) { - e.printStackTrace(); - } - lock.unlock(); - }).start(); - TimeUnit.SECONDS.sleep(1); - lock.lock(); - System.out.println("当前Condition的等待线程数:"+lock.getWaitQueueLength(condition)); - condition.signal(); - System.out.println("当前Condition的等待线程数:"+lock.getWaitQueueLength(condition)); - lock.unlock(); -} -``` - -通过使用`getWaitQueueLength()`方法能够查看同一个Condition目前有多少线程处于等待状态。 - -#### 公平锁与非公平锁 - -前面我们了解了如果线程之间争抢同一把锁,会暂时进入到等待队列中,那么多个线程获得锁的顺序是不是一定是根据线程调用`lock()`方法时间来定的呢,我们可以看到,`ReentrantLock`的构造方法中,是这样写的: - -```java -public ReentrantLock() { - sync = new NonfairSync(); //看名字貌似是非公平的 -} -``` - -其实锁分为公平锁和非公平锁,默认我们创建出来的ReentrantLock是采用的非公平锁作为底层锁机制。那么什么是公平锁什么又是非公平锁呢? - -* 公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。 -* 非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。 - -简单来说,公平锁不让插队,都老老实实排着;非公平锁让插队,但是排队的人让不让你插队就是另一回事了。 - -我们可以来测试一下公平锁和非公平锁的表现情况: - -```java -public ReentrantLock(boolean fair) { - sync = fair ? new FairSync() : new NonfairSync(); -} -``` - -这里我们选择使用第二个构造方法,可以选择是否为公平锁实现: - -```java -public static void main(String[] args) throws InterruptedException { - ReentrantLock lock = new ReentrantLock(false); - - Runnable action = () -> { - System.out.println("线程 "+Thread.currentThread().getName()+" 开始获取锁..."); - lock.lock(); - System.out.println("线程 "+Thread.currentThread().getName()+" 成功获取锁!"); - lock.unlock(); - }; - for (int i = 0; i < 10; i++) { //建立10个线程 - new Thread(action, "T"+i).start(); - } -} -``` - -这里我们只需要对比`将在1秒后开始获取锁...`和`成功获取锁!`的顺序是否一致即可,如果是一致,那说明所有的线程都是按顺序排队获取的锁,如果不是,那说明肯定是有线程插队了。 - -运行结果可以发现,在公平模式下,确实是按照顺序进行的,而在非公平模式下,一般会出现这种情况:线程刚开始获取锁马上就能抢到,并且此时之前早就开始的线程还在等待状态,很明显的插队行为。 - -那么,接着下一个问题,公平锁在任何情况下都一定是公平的吗?有关这个问题,我们会留到队列同步器中再进行讨论。 - -*** - -### 读写锁 - -除了可重入锁之外,还有一种类型的锁叫做读写锁,当然它并不是专门用作读写操作的锁,它和可重入锁不同的地方在于,可重入锁是一种排他锁,当一个线程得到锁之后,另一个线程必须等待其释放锁,否则一律不允许获取到锁。而读写锁在同一时间,是可以让多个线程获取到锁的,它其实就是针对于读写场景而出现的。 - -读写锁维护了一个读锁和一个写锁,这两个锁的机制是不同的。 - -* 读锁:在没有任何线程占用写锁的情况下,同一时间可以有多个线程加读锁。 -* 写锁:在没有任何线程占用读锁的情况下,同一时间只能有一个线程加写锁。 - -读写锁也有一个专门的接口: - -```java -public interface ReadWriteLock { - //获取读锁 - Lock readLock(); - - //获取写锁 - Lock writeLock(); -} -``` - -此接口有一个实现类ReentrantReadWriteLock(实现的是ReadWriteLock接口,不是Lock接口,它本身并不是锁),注意我们操作ReentrantReadWriteLock时,不能直接上锁,而是需要获取读锁或是写锁,再进行锁操作: - -```java -public static void main(String[] args) throws InterruptedException { - ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); - lock.readLock().lock(); - new Thread(lock.readLock()::lock).start(); -} -``` - -这里我们对读锁加锁,可以看到可以多个线程同时对读锁加锁。 - -```java -public static void main(String[] args) throws InterruptedException { - ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); - lock.readLock().lock(); - new Thread(lock.writeLock()::lock).start(); -} -``` - -有读锁状态下无法加写锁,反之亦然: - -```java -public static void main(String[] args) throws InterruptedException { - ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); - lock.writeLock().lock(); - new Thread(lock.readLock()::lock).start(); -} -``` - -并且,ReentrantReadWriteLock不仅具有读写锁的功能,还保留了可重入锁和公平/非公平机制,比如同一个线程可以重复为写锁加锁,并且必须全部解锁才真正释放锁: - -```java -public static void main(String[] args) throws InterruptedException { - ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); - lock.writeLock().lock(); - lock.writeLock().lock(); - new Thread(() -> { - lock.writeLock().lock(); - System.out.println("成功获取到写锁!"); - }).start(); - System.out.println("释放第一层锁!"); - lock.writeLock().unlock(); - TimeUnit.SECONDS.sleep(1); - System.out.println("释放第二层锁!"); - lock.writeLock().unlock(); -} -``` - -通过之前的例子来验证公平和非公平: - -```java -public static void main(String[] args) throws InterruptedException { - ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true); - - Runnable action = () -> { - System.out.println("线程 "+Thread.currentThread().getName()+" 将在1秒后开始获取锁..."); - lock.writeLock().lock(); - System.out.println("线程 "+Thread.currentThread().getName()+" 成功获取锁!"); - lock.writeLock().unlock(); - }; - for (int i = 0; i < 10; i++) { //建立10个线程 - new Thread(action, "T"+i).start(); - } -} -``` - -可以看到,结果是一致的。 - -#### 锁降级和锁升级 - -锁降级指的是写锁降级为读锁。当一个线程持有写锁的情况下,虽然其他线程不能加读锁,但是线程自己是可以加读锁的: - -```java -public static void main(String[] args) throws InterruptedException { - ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); - lock.writeLock().lock(); - lock.readLock().lock(); - System.out.println("成功加读锁!"); -} -``` - -那么,如果我们在同时加了写锁和读锁的情况下,释放写锁,是否其他的线程就可以一起加读锁了呢? - -```java -public static void main(String[] args) throws InterruptedException { - ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); - lock.writeLock().lock(); - lock.readLock().lock(); - new Thread(() -> { - System.out.println("开始加读锁!"); - lock.readLock().lock(); - System.out.println("读锁添加成功!"); - }).start(); - TimeUnit.SECONDS.sleep(1); - lock.writeLock().unlock(); //如果释放写锁,会怎么样? -} -``` - -可以看到,一旦写锁被释放,那么主线程就只剩下读锁了,因为读锁可以被多个线程共享,所以这时第二个线程也添加了读锁。而这种操作,就被称之为"锁降级"(注意不是先释放写锁再加读锁,而是持有写锁的情况下申请读锁再释放写锁) - -注意在仅持有读锁的情况下去申请写锁,属于"锁升级",ReentrantReadWriteLock是不支持的: - -```java -public static void main(String[] args) throws InterruptedException { - ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); - lock.readLock().lock(); - lock.writeLock().lock(); - System.out.println("所升级成功!"); -} -``` - -可以看到线程直接卡在加写锁的那一句了。 - -### 队列同步器AQS - -**注意:**难度巨大,如果对锁的使用不是很熟悉建议之后再来看! - -前面我们了解了可重入锁和读写锁,那么它们的底层实现原理到底是什么样的呢?又是大家看到就想跳过的套娃解析环节。 - -比如我们执行了ReentrantLock的`lock()`方法,那它的内部是怎么在执行的呢? - -```java -public void lock() { - sync.lock(); -} -``` - -可以看到,它的内部实际上啥都没做,而是交给了Sync对象在进行,并且,不只是这个方法,其他的很多方法都是依靠Sync对象在进行: - -```java -public void unlock() { - sync.release(1); -} -``` - -那么这个Sync对象是干什么的呢?可以看到,公平锁和非公平锁都是继承自Sync,而Sync是继承自AbstractQueuedSynchronizer,简称队列同步器: - -```java -abstract static class Sync extends AbstractQueuedSynchronizer { - //... -} - -static final class NonfairSync extends Sync {} -static final class FairSync extends Sync {} -``` - -所以,要了解它的底层到底是如何进行操作的,还得看队列同步器,我们就先从这里下手吧! - -#### 底层实现 - -AbstractQueuedSynchronizer(下面称为AQS)是实现锁机制的基础,它的内部封装了包括锁的获取、释放、以及等待队列。 - -一个锁(排他锁为例)的基本功能就是获取锁、释放锁、当锁被占用时,其他线程来争抢会进入等待队列,AQS已经将这些基本的功能封装完成了,其中等待队列是核心内容,等待队列是由双向链表数据结构实现的,每个等待状态下的线程都可以被封装进结点中并放入双向链表中,而对于双向链表是以队列的形式进行操作的,它像这样: - -![image-20220306162015545](https://tva1.sinaimg.cn/large/e6c9d24ely1h008jltp0zj212k0b4tac.jpg) - -AQS中有一个`head`字段和一个`tail`字段分别记录双向链表的头结点和尾结点,而之后的一系列操作都是围绕此队列来进行的。我们先来了解一下每个结点都包含了哪些内容: - -```java -//每个处于等待状态的线程都可以是一个节点,并且每个节点是有很多状态的 -static final class Node { - //每个节点都可以被分为独占模式节点或是共享模式节点,分别适用于独占锁和共享锁 - static final Node SHARED = new Node(); - static final Node EXCLUSIVE = null; - - //等待状态,这里都定义好了 - //唯一一个大于0的状态,表示已失效,可能是由于超时或中断,此节点被取消。 - static final int CANCELLED = 1; - //此节点后面的节点被挂起(进入等待状态) - static final int SIGNAL = -1; - //在条件队列中的节点才是这个状态 - static final int CONDITION = -2; - //传播,一般用于共享锁 - static final int PROPAGATE = -3; - - volatile int waitStatus; //等待状态值 - volatile Node prev; //双向链表基操 - volatile Node next; - volatile Thread thread; //每一个线程都可以被封装进一个节点进入到等待队列 - - Node nextWaiter; //在等待队列中表示模式,条件队列中作为下一个结点的指针 - - final boolean isShared() { - return nextWaiter == SHARED; - } - - final Node predecessor() throws NullPointerException { - Node p = prev; - if (p == null) - throw new NullPointerException(); - else - return p; - } - - Node() { - } - - Node(Thread thread, Node mode) { - this.nextWaiter = mode; - this.thread = thread; - } - - Node(Thread thread, int waitStatus) { - this.waitStatus = waitStatus; - this.thread = thread; - } -} -``` - -在一开始的时候,`head`和`tail`都是`null`,`state`为默认值`0`: - -```java -private transient volatile Node head; - -private transient volatile Node tail; - -private volatile int state; -``` - -不用担心双向链表不会进行初始化,初始化是在实际使用时才开始的,先不管,我们接着来看其他的初始化内容: - -```java -//直接使用Unsafe类进行操作 -private static final Unsafe unsafe = Unsafe.getUnsafe(); -//记录类中属性的在内存中的偏移地址,方便Unsafe类直接操作内存进行赋值等(直接修改对应地址的内存) -private static final long stateOffset; //这里对应的就是AQS类中的state成员字段 -private static final long headOffset; //这里对应的就是AQS类中的head头结点成员字段 -private static final long tailOffset; -private static final long waitStatusOffset; -private static final long nextOffset; - -static { //静态代码块,在类加载的时候就会自动获取偏移地址 - try { - stateOffset = unsafe.objectFieldOffset - (AbstractQueuedSynchronizer.class.getDeclaredField("state")); - headOffset = unsafe.objectFieldOffset - (AbstractQueuedSynchronizer.class.getDeclaredField("head")); - tailOffset = unsafe.objectFieldOffset - (AbstractQueuedSynchronizer.class.getDeclaredField("tail")); - waitStatusOffset = unsafe.objectFieldOffset - (Node.class.getDeclaredField("waitStatus")); - nextOffset = unsafe.objectFieldOffset - (Node.class.getDeclaredField("next")); - - } catch (Exception ex) { throw new Error(ex); } -} - -//通过CAS操作来修改头结点 -private final boolean compareAndSetHead(Node update) { - //调用的是Unsafe类的compareAndSwapObject方法,通过CAS算法比较对象并替换 - return unsafe.compareAndSwapObject(this, headOffset, null, update); -} - -//同上,省略部分代码 -private final boolean compareAndSetTail(Node expect, Node update) { - -private static final boolean compareAndSetWaitStatus(Node node, int expect, int update) { - -private static final boolean compareAndSetNext(Node node, Node expect, Node update) { -``` - -可以发现,队列同步器由于要使用到CAS算法,所以,直接使用了Unsafe工具类,Unsafe类中提供了CAS操作的方法(Java无法实现,底层由C++实现)所有对AQS类中成员字段的修改,都有对应的CAS操作封装。 - -现在我们大致了解了一下它的底层运作机制,我们接着来看这个类是如何进行使用的,它提供了一些可重写的方法(根据不同的锁类型和机制,可以自由定制规则,并且为独占式和非独占式锁都提供了对应的方法),以及一些已经写好的模板方法(模板方法会调用这些可重写的方法),使用此类只需要将可重写的方法进行重写,并调用提供的模板方法,从而实现锁功能(学习过设计模式会比较好理解一些) - -我们首先来看可重写方法: - -```java -//独占式获取同步状态,查看同步状态是否和参数一致,如果返没有问题,那么会使用CAS操作设置同步状态并返回true -protected boolean tryAcquire(int arg) { - throw new UnsupportedOperationException(); -} - -//独占式释放同步状态 -protected boolean tryRelease(int arg) { - throw new UnsupportedOperationException(); -} - -//共享式获取同步状态,返回值大于0表示成功,否则失败 -protected int tryAcquireShared(int arg) { - throw new UnsupportedOperationException(); -} - -//共享式释放同步状态 -protected boolean tryReleaseShared(int arg) { - throw new UnsupportedOperationException(); -} - -//是否在独占模式下被当前线程占用(锁是否被当前线程持有) -protected boolean isHeldExclusively() { - throw new UnsupportedOperationException(); -} -``` - -可以看到,这些需要重写的方法默认是直接抛出`UnsupportedOperationException`,也就是说根据不同的锁类型,我们需要去实现对应的方法,我们可以来看一下ReentrantLock(此类是全局独占式的)中的公平锁是如何借助AQS实现的: - -```java -static final class FairSync extends Sync { - private static final long serialVersionUID = -3000897897090466540L; - - //加锁操作调用了模板方法acquire - //为了防止各位绕晕,请时刻记住,lock方法一定是在某个线程下为了加锁而调用的,并且同一时间可能会有其他线程也在调用此方法 - final void lock() { - acquire(1); - } - - ... -} -``` - -我们先看看加锁操作干了什么事情,这里直接调用了AQS提供的模板方法`acquire()`,我们来看看它在AQS类中的实现细节: - -```java -@ReservedStackAccess //这个是JEP 270添加的新注解,它会保护被注解的方法,通过添加一些额外的空间,防止在多线程运行的时候出现栈溢出,下同 -public final void acquire(int arg) { - if (!tryAcquire(arg) && - acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) //节点为独占模式Node.EXCLUSIVE - selfInterrupt(); -} -``` - -首先会调用`tryAcquire()`方法(这里是由FairSync类实现的),如果尝试加独占锁失败(返回false了)说明可能这个时候有其他线程持有了此独占锁,所以当前线程得先等着,那么会调用`addWaiter()`方法将线程加入等待队列中: - -```java -private Node addWaiter(Node mode) { - Node node = new Node(Thread.currentThread(), mode); - // 先尝试使用CAS直接入队,如果这个时候其他线程也在入队(就是不止一个线程在同一时间争抢这把锁)就进入enq() - Node pred = tail; - if (pred != null) { - node.prev = pred; - if (compareAndSetTail(pred, node)) { - pred.next = node; - return node; - } - } - //此方法是CAS快速入队失败时调用 - enq(node); - return node; -} - -private Node enq(final Node node) { - //自旋形式入队,可以看到这里是一个无限循环 - for (;;) { - Node t = tail; - if (t == null) { //这种情况只能说明头结点和尾结点都还没初始化 - if (compareAndSetHead(new Node())) //初始化头结点和尾结点 - tail = head; - } else { - node.prev = t; - if (compareAndSetTail(t, node)) { - t.next = node; - return t; //只有CAS成功的情况下,才算入队成功,如果CAS失败,那说明其他线程同一时间也在入队,并且手速还比当前线程快,刚好走到CAS操作的时候,其他线程就先入队了,那么这个时候node.prev就不是我们预期的节点了,而是另一个线程新入队的节点,所以说得进下一次循环再来一次CAS,这种形式就是自旋 - } - } - } -} -``` - -在了解了`addWaiter()`方法会将节点加入等待队列之后,我们接着来看,`addWaiter()`会返回已经加入的节点,`acquireQueued()`在得到返回的节点时,也会进入自旋状态,等待唤醒(也就是开始进入到拿锁的环节了): - -```java -@ReservedStackAccess -final boolean acquireQueued(final Node node, int arg) { - boolean failed = true; - try { - boolean interrupted = false; - for (;;) { - final Node p = node.predecessor(); - if (p == head && tryAcquire(arg)) { //可以看到当此节点位于队首(node.prev == head)时,会再次调用tryAcquire方法获取锁,如果获取成功,会返回此过程中是否被中断的值 - setHead(node); //新的头结点设置为当前结点 - p.next = null; // 原有的头结点没有存在的意义了 - failed = false; //没有失败 - return interrupted; //直接返回等待过程中是否被中断 - } - //依然没获取成功, - if (shouldParkAfterFailedAcquire(p, node) && //将当前节点的前驱节点等待状态设置为SIGNAL,如果失败将直接开启下一轮循环,直到成功为止,如果成功接着往下 - parkAndCheckInterrupt()) //挂起线程进入等待状态,等待被唤醒,如果在等待状态下被中断,那么会返回true,直接将中断标志设为true,否则就是正常唤醒,继续自旋 - interrupted = true; - } - } finally { - if (failed) - cancelAcquire(node); - } -} - -private final boolean parkAndCheckInterrupt() { - LockSupport.park(this); //通过unsafe类操作底层挂起线程(会直接进入阻塞状态) - return Thread.interrupted(); -} -``` - -```java -private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { - int ws = pred.waitStatus; - if (ws == Node.SIGNAL) - return true; //已经是SIGNAL,直接true - if (ws > 0) { //不能是已经取消的节点,必须找到一个没被取消的 - do { - node.prev = pred = pred.prev; - } while (pred.waitStatus > 0); - pred.next = node; //直接抛弃被取消的节点 - } else { - //不是SIGNAL,先CAS设置为SIGNAL(这里没有返回true因为CAS不一定成功,需要下一轮再判断一次) - compareAndSetWaitStatus(pred, ws, Node.SIGNAL); - } - return false; //返回false,马上开启下一轮循环 -} -``` - -所以,`acquire()`中的if条件如果为true,那么只有一种情况,就是等待过程中被中断了,其他任何情况下都是成功获取到独占锁,所以当等待过程被中断时,会调用`selfInterrupt()`方法: - -```java -static void selfInterrupt() { - Thread.currentThread().interrupt(); -} -``` - -这里就是直接向当前线程发送中断信号了。 - -上面提到了LockSupport类,它是一个工具类,我们也可以来玩一下这个`park`和`unpark`: - -```java -public static void main(String[] args) throws InterruptedException { - Thread t = Thread.currentThread(); //先拿到主线程的Thread对象 - new Thread(() -> { - try { - TimeUnit.SECONDS.sleep(1); - System.out.println("主线程可以继续运行了!"); - LockSupport.unpark(t); - //t.interrupt(); 发送中断信号也可以恢复运行 - } catch (InterruptedException e) { - e.printStackTrace(); - } - }).start(); - System.out.println("主线程被挂起!"); - LockSupport.park(); - System.out.println("主线程继续运行!"); -} -``` - -这里我们就把公平锁的`lock()`方法实现讲解完毕了(让我猜猜,已经晕了对吧,越是到源码越考验个人的基础知识掌握,基础不牢地动山摇)接着我们来看公平锁的`tryAcquire()`方法: - -```java -static final class FairSync extends Sync { - //可重入独占锁的公平实现 - @ReservedStackAccess - protected final boolean tryAcquire(int acquires) { - final Thread current = Thread.currentThread(); //先获取当前线程的Thread对象 - int c = getState(); //获取当前AQS对象状态(独占模式下0为未占用,大于0表示已占用) - if (c == 0) { //如果是0,那就表示没有占用,现在我们的线程就要来尝试占用它 - if (!hasQueuedPredecessors() && //等待队列是否不为空且当前线程没有拿到锁,其实就是看看当前线程有没有必要进行排队,如果没必要排队,就说明可以直接获取锁 - compareAndSetState(0, acquires)) { //CAS设置状态,如果成功则说明成功拿到了这把锁,失败则说明可能这个时候其他线程在争抢,并且还比你先抢到 - setExclusiveOwnerThread(current); //成功拿到锁,会将独占模式所有者线程设定为当前线程(这个方法是父类AbstractOwnableSynchronizer中的,就表示当前这把锁已经是这个线程的了) - return true; //占用锁成功,返回true - } - } - else if (current == getExclusiveOwnerThread()) { //如果不是0,那就表示被线程占用了,这个时候看看是不是自己占用的,如果是,由于是可重入锁,可以继续加锁 - int nextc = c + acquires; //多次加锁会将状态值进行增加,状态值就是加锁次数 - if (nextc < 0) //加到int值溢出了? - throw new Error("Maximum lock count exceeded"); - setState(nextc); //设置为新的加锁次数 - return true; - } - return false; //其他任何情况都是加锁失败 - } -} -``` - -在了解了公平锁的实现之后,是不是感觉有点恍然大悟的感觉,虽然整个过程非常复杂,但是只要理清思路,还是比较简单的。 - -加锁过程已经OK,我们接着来看,它的解锁过程,`unlock()`方法是在AQS中实现的: - -```java -public void unlock() { - sync.release(1); //直接调用了AQS中的release方法,参数为1表示解锁一次state值-1 -} -``` - -```java -@ReservedStackAccess -public final boolean release(int arg) { - if (tryRelease(arg)) { //和tryAcquire一样,也得子类去重写,释放锁操作 - Node h = head; //释放锁成功后,获取新的头结点 - if (h != null && h.waitStatus != 0) //如果新的头结点不为空并且不是刚刚建立的结点(初始状态下status为默认值0,而上面在进行了shouldParkAfterFailedAcquire之后,会被设定为SIGNAL状态,值为-1) - unparkSuccessor(h); //唤醒头节点下一个节点中的线程 - return true; - } - return false; -} -``` - -```java -private void unparkSuccessor(Node node) { - // 将等待状态waitStatus设置为初始值0 - int ws = node.waitStatus; - if (ws < 0) - compareAndSetWaitStatus(node, ws, 0); - - //获取下一个结点 - Node s = node.next; - if (s == null || s.waitStatus > 0) { //如果下一个结点为空或是等待状态是已取消,那肯定是不能通知unpark的,这时就要遍历所有节点再另外找一个符合unpark要求的节点了 - s = null; - for (Node t = tail; t != null && t != node; t = t.prev) //这里是从队尾向前,因为enq()方法中的t.next = node是在CAS之后进行的,而 node.prev = t 是CAS之前进行的,所以从后往前一定能够保证遍历所有节点 - if (t.waitStatus <= 0) - s = t; - } - if (s != null) //要是找到了,就直接unpark,要是还是没找到,那就算了 - LockSupport.unpark(s.thread); -} -``` - -那么我们来看看`tryRelease()`方法是怎么实现的,具体实现在Sync中: - -```java -@ReservedStackAccess -protected final boolean tryRelease(int releases) { - int c = getState() - releases; //先计算本次解锁之后的状态值 - if (Thread.currentThread() != getExclusiveOwnerThread()) //因为是独占锁,那肯定这把锁得是当前线程持有才行 - throw new IllegalMonitorStateException(); //否则直接抛异常 - boolean free = false; - if (c == 0) { //如果解锁之后的值为0,表示已经完全释放此锁 - free = true; - setExclusiveOwnerThread(null); //将独占锁持有线程设置为null - } - setState(c); //状态值设定为c - return free; //如果不是0表示此锁还没完全释放,返回false,是0就返回true -} -``` - -综上,我们来画一个完整的流程图: - -![image-20220306141248030](https://tva1.sinaimg.cn/large/e6c9d24ely1h004uzeni8j224k0ca0w9.jpg) - -这里我们只讲解了公平锁,有关非公平锁和读写锁,还请各位观众根据我们之前的思路,自行解读。 - -#### 公平锁一定公平吗? - -前面我们讲解了公平锁的实现原理,那么,我们尝试分析一下,在并发的情况下,公平锁一定公平吗? - -我们再次来回顾一下`tryAcquire()`方法的实现: - -```java -@ReservedStackAccess -protected final boolean tryAcquire(int acquires) { - final Thread current = Thread.currentThread(); - int c = getState(); - if (c == 0) { - if (!hasQueuedPredecessors() && //注意这里,公平锁的机制是,一开始会查看是否有节点处于等待 - compareAndSetState(0, acquires)) { //如果前面的方法执行后发现没有等待节点,就直接进入占锁环节了 - setExclusiveOwnerThread(current); - return true; - } - } - else if (current == getExclusiveOwnerThread()) { - int nextc = c + acquires; - if (nextc < 0) - throw new Error("Maximum lock count exceeded"); - setState(nextc); - return true; - } - return false; -} -``` - -所以`hasQueuedPredecessors()`这个环节容不得半点闪失,否则会直接破坏掉公平性,假如现在出现了这样的情况: - -线程1已经持有锁了,这时线程2来争抢这把锁,走到`hasQueuedPredecessors()`,判断出为 `false`,线程2继续运行,然后线程2肯定获取锁失败(因为锁这时是被线程1占有的),因此就进入到等待队列中: - -```java -private Node enq(final Node node) { - for (;;) { - Node t = tail; - if (t == null) { // 线程2进来之后,肯定是要先走这里的,因为head和tail都是null - if (compareAndSetHead(new Node())) - tail = head; //这里就将tail直接等于head了,注意这里完了之后还没完,这里只是初始化过程 - } else { - node.prev = t; - if (compareAndSetTail(t, node)) { - t.next = node; - return t; - } - } - } -} - -private Node addWaiter(Node mode) { - Node node = new Node(Thread.currentThread(), mode); - Node pred = tail; - if (pred != null) { //由于一开始head和tail都是null,所以线程2直接就进enq()了 - node.prev = pred; - if (compareAndSetTail(pred, node)) { - pred.next = node; - return node; - } - } - enq(node); //请看上面 - return node; -} -``` - -而碰巧不巧,这个时候线程3也来抢锁了,按照正常流程走到了`hasQueuedPredecessors()`方法,而在此方法中: - -```java -public final boolean hasQueuedPredecessors() { - Node t = tail; // Read fields in reverse initialization order - Node h = head; - Node s; - //这里直接判断h != t,而此时线程2才刚刚执行完 tail = head,所以直接就返回false了 - return h != t && - ((s = h.next) == null || s.thread != Thread.currentThread()); -} -``` - -因此,线程3这时就紧接着准备开始CAS操作了,又碰巧,这时线程1释放锁了,现在的情况就是,线程3直接开始CAS判断,而线程2还在插入节点状态,结果可想而知,居然是线程3先拿到了锁,这显然是违背了公平锁的公平机制。 - -一张图就是: - -![image-20220306155509195](https://tva1.sinaimg.cn/large/e6c9d24ely1h007thq2x1j22ce0k879c.jpg) - -因此公不公平全看`hasQueuedPredecessors()`,而此方法只有在等待队列中存在节点时才能保证不会出现问题。所以公平锁,只有在等待队列存在节点时,才是真正公平的。 - -#### Condition实现原理 - -通过前面的学习,我们知道Condition类实际上就是用于代替传统对象的wait/notify操作的,同样可以实现等待/通知模式,并且同一把锁下可以创建多个Condition对象。那么我们接着来看看,它又是如何实现的呢,我们先从单个Condition对象进行分析: - -在AQS中,Condition有一个实现类ConditionObject,而这里也是使用了链表实现了条件队列: - -```java -public class ConditionObject implements Condition, java.io.Serializable { - private static final long serialVersionUID = 1173984872572414699L; - /** 条件队列的头结点 */ - private transient Node firstWaiter; - /** 条件队列的尾结点 */ - private transient Node lastWaiter; - - //... -``` - -这里是直接使用了AQS中的Node类,但是使用的是Node类中的nextWaiter字段连接节点,并且Node的status为CONDITION: - -![image-20220307115850295](https://tva1.sinaimg.cn/large/e6c9d24ely1h016lyg63ij21ew0dsgnt.jpg) - -我们知道,当一个线程调用`await()`方法时,会进入等待状态,直到其他线程调用`signal()`方法将其唤醒,而这里的条件队列,正是用于存储这些处于等待状态的线程。 - -我们先来看看最关键的`await()`方法是如何实现的,为了防止一会绕晕,在开始之前,我们先明确此方法的目标: - -* 只有已经持有锁的线程才可以使用此方法 -* 当调用此方法后,会直接释放锁,无论加了多少次锁 -* 只有其他线程调用`signal()`或是被中断时才会唤醒等待中的线程 -* 被唤醒后,需要等待其他线程释放锁,拿到锁之后才可以继续执行,并且会恢复到之前的状态(await之前加了几层锁唤醒后依然是几层锁) - -好了,差不多可以上源码了: - -```java -public final void await() throws InterruptedException { - if (Thread.interrupted()) - throw new InterruptedException(); //如果在调用await之前就被添加了中断标记,那么会直接抛出中断异常 - Node node = addConditionWaiter(); //为当前线程创建一个新的节点,并将其加入到条件队列中 - int savedState = fullyRelease(node); //完全释放当前线程持有的锁,并且保存一下state值,因为唤醒之后还得恢复 - int interruptMode = 0; //用于保存中断状态 - while (!isOnSyncQueue(node)) { //循环判断是否位于同步队列中,如果等待状态下的线程被其他线程唤醒,那么会正常进入到AQS的等待队列中(之后我们会讲) - LockSupport.park(this); //如果依然处于等待状态,那么继续挂起 - if ((interruptMode = checkInterruptWhileWaiting(node)) != 0) //看看等待的时候是不是被中断了 - break; - } - //出了循环之后,那线程肯定是已经醒了,这时就差拿到锁就可以恢复运行了 - if (acquireQueued(node, savedState) && interruptMode != THROW_IE) //直接开始acquireQueued尝试拿锁(之前已经讲过了)从这里开始基本就和一个线程去抢锁是一样的了 - interruptMode = REINTERRUPT; - //已经拿到锁了,基本可以开始继续运行了,这里再进行一下后期清理工作 - if (node.nextWaiter != null) - unlinkCancelledWaiters(); //将等待队列中,不是Node.CONDITION状态的节点移除 - if (interruptMode != 0) //依然是响应中断 - reportInterruptAfterWait(interruptMode); - //OK,接着该干嘛干嘛 -} -``` - -实际上`await()`方法比较中规中矩,大部分操作也在我们的意料之中,那么我们接着来看`signal()`方法是如何实现的,同样的,为了防止各位绕晕,先明确signal的目标: - -* 只有持有锁的线程才能唤醒锁所属的Condition等待的线程 -* 优先唤醒条件队列中的第一个,如果唤醒过程中出现问题,接着找往下找,直到找到一个可以唤醒的 -* 唤醒操作本质上是将条件队列中的结点直接丢进AQS等待队列中,让其参与到锁的竞争中 -* 拿到锁之后,线程才能恢复运行 - -![image-20220307120449303](https://tva1.sinaimg.cn/large/e6c9d24ely1h016s4p0rfj21as0hg76w.jpg) - -好了,上源码: - -```java -public final void signal() { - if (!isHeldExclusively()) //先看看当前线程是不是持有锁的状态 - throw new IllegalMonitorStateException(); //不是?那你不配唤醒别人 - Node first = firstWaiter; //获取条件队列的第一个结点 - if (first != null) //如果队列不为空,获取到了,那么就可以开始唤醒操作 - doSignal(first); -} -``` - -```java -private void doSignal(Node first) { - do { - if ( (firstWaiter = first.nextWaiter) == null) //如果当前节点在本轮循环没有后继节点了,条件队列就为空了 - lastWaiter = null; //所以这里相当于是直接清空 - first.nextWaiter = null; //将给定节点的下一个结点设置为null,因为当前结点马上就会离开条件队列了 - } while (!transferForSignal(first) && //接着往下看 - (first = firstWaiter) != null); //能走到这里只能说明给定节点被设定为了取消状态,那就继续看下一个结点 -} -``` - -```java -final boolean transferForSignal(Node node) { - /* - * 如果这里CAS失败,那有可能此节点被设定为了取消状态 - */ - if (!compareAndSetWaitStatus(node, Node.CONDITION, 0)) - return false; - - //CAS成功之后,结点的等待状态就变成了默认值0,接着通过enq方法直接将节点丢进AQS的等待队列中,相当于唤醒并且可以等待获取锁了 - //这里enq方法返回的是加入之后等待队列队尾的前驱节点,就是原来的tail - Node p = enq(node); - int ws = p.waitStatus; //保存前驱结点的等待状态 - //如果上一个节点的状态为取消, 或者尝试设置上一个节点的状态为SIGNAL失败(可能是在ws>0判断完之后马上变成了取消状态,导致CAS失败) - if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL)) - LockSupport.unpark(node.thread); //直接唤醒线程 - return true; -} -``` - -其实最让人不理解的就是倒数第二行,明明上面都正常进入到AQS等待队列了,应该是可以开始走正常流程了,那么这里为什么还要提前来一次unpark呢? - -这里其实是为了进行优化而编写,直接unpark会有两种情况: - -- 如果插入结点前,AQS等待队列的队尾节点就已经被取消,则满足wc > 0 -- 如果插入node后,AQS内部等待队列的队尾节点已经稳定,满足tail.waitStatus == 0,但在执行ws > - 0之后!compareAndSetWaitStatus(p, ws, - Node.SIGNAL)之前被取消,则CAS也会失败,满足compareAndSetWaitStatus(p, ws, - Node.SIGNAL) == false - -如果这里被提前unpark,那么在`await()`方法中将可以被直接唤醒,并跳出while循环,直接开始争抢锁,因为前一个等待结点是被取消的状态,没有必要再等它了。 - -所以,大致流程下: - -![image-20220307131536020](https://tva1.sinaimg.cn/large/e6c9d24ely1h018ts8x80j21to0eidix.jpg) - -只要把整个流程理清楚,还是很好理解的。 - -#### 自行实现锁类 - -既然前面了解了那么多AQS的功能,那么我就仿照着这些锁类来实现一个简单的锁: - -* 要求:同一时间只能有一个线程持有锁,不要求可重入(反复加锁无视即可) - -```java -public class Main { - public static void main(String[] args) throws InterruptedException { - - } - - /** - * 自行实现一个最普通的独占锁 - * 要求:同一时间只能有一个线程持有锁,不要求可重入 - */ - private static class MyLock implements Lock { - - /** - * 设计思路: - * 1. 锁被占用,那么exclusiveOwnerThread应该被记录,并且state = 1 - * 2. 锁没有被占用,那么exclusiveOwnerThread为null,并且state = 0 - */ - private static class Sync extends AbstractQueuedSynchronizer { - @Override - protected boolean tryAcquire(int arg) { - if(isHeldExclusively()) return true; //无需可重入功能,如果是当前线程直接返回true - if(compareAndSetState(0, arg)){ //CAS操作进行状态替换 - setExclusiveOwnerThread(Thread.currentThread()); //成功后设置当前的所有者线程 - return true; - } - return false; - } - - @Override - protected boolean tryRelease(int arg) { - if(getState() == 0) - throw new IllegalMonitorStateException(); //没加锁情况下是不能直接解锁的 - if(isHeldExclusively()){ //只有持有锁的线程才能解锁 - setExclusiveOwnerThread(null); //设置所有者线程为null - setState(0); //状态变为0 - return true; - } - return false; - } - - @Override - protected boolean isHeldExclusively() { - return getExclusiveOwnerThread() == Thread.currentThread(); - } - - protected Condition newCondition(){ - return new ConditionObject(); //直接用现成的 - } - } - - private final Sync sync = new Sync(); - - @Override - public void lock() { - sync.acquire(1); - } - - @Override - public void lockInterruptibly() throws InterruptedException { - sync.acquireInterruptibly(1); - } - - @Override - public boolean tryLock() { - return sync.tryAcquire(1); - } - - @Override - public boolean tryLock(long time, TimeUnit unit) throws InterruptedException { - return sync.tryAcquireNanos(1, unit.toNanos(time)); - } - - @Override - public void unlock() { - sync.release(1); - } - - @Override - public Condition newCondition() { - return sync.newCondition(); - } - } -} -``` - -到这里,我们对应队列同步器AQS的讲解就先到此为止了,当然,AQS的全部机制并非仅仅只有我们讲解的内容,一些我们没有提到的内容,还请各位观众自行探索,会有满满的成就感哦~ - -*** - -## 原子类 - -前面我们讲解了锁框架的使用和实现原理,虽然比较复杂,但是收获还是很多的(主要是观摩大佬写的代码)这一部分我们就来讲一点轻松的。 - -前面我们说到,如果要保证`i++`的原子性,那么我们的唯一选择就是加锁,那么,除了加锁之外,还有没有其他更好的解决方法呢?JUC为我们提供了原子类,底层采用CAS算法,它是一种用法简单、性能高效、线程安全地更新变量的方式。 - -所有的原子类都位于`java.util.concurrent.atomic`包下。 - -### 原子类介绍 - -常用基本数据类,有对应的原子类封装: - -* AtomicInteger:原子更新int -* AtomicLong:原子更新long -* AtomicBoolean:原子更新boolean - -那么,原子类和普通的基本类在使用上有没有什么区别呢?我们先来看正常情况下使用一个基本类型: - -```java -public class Main { - public static void main(String[] args) { - int i = 1; - System.out.println(i++); - } -} -``` - -现在我们使用int类型对应的原子类,要实现同样的代码该如何编写: - -```java -public class Main { - public static void main(String[] args) { - AtomicInteger i = new AtomicInteger(1); - System.out.println(i.getAndIncrement()); //如果想实现i += 2这种操作,可以使用 addAndGet() 自由设置delta 值 - } -} -``` - -我们可以将int数值封装到此类中(注意必须调用构造方法,它不像Integer那样有装箱机制),并且通过调用此类提供的方法来获取或是对封装的int值进行自增,乍一看,这不就是基本类型包装类嘛,有啥高级的。确实,还真有包装类那味,但是它可不仅仅是简单的包装,它的自增操作是具有原子性的: - -```java -public class Main { - private static AtomicInteger i = new AtomicInteger(0); - public static void main(String[] args) throws InterruptedException { - Runnable r = () -> { - for (int j = 0; j < 100000; j++) - i.getAndIncrement(); - System.out.println("自增完成!"); - }; - new Thread(r).start(); - new Thread(r).start(); - TimeUnit.SECONDS.sleep(1); - System.out.println(i.get()); - } -} -``` - -同样是直接进行自增操作,我们发现,使用原子类是可以保证自增操作原子性的,就跟我们前面加锁一样。怎么会这么神奇?我们来看看它的底层是如何实现的,直接从构造方法点进去: - -```java -private volatile int value; - -public AtomicInteger(int initialValue) { - value = initialValue; -} - -public AtomicInteger() { -} -``` - -可以看到,它的底层是比较简单的,其实本质上就是封装了一个`volatile`类型的int值,这样能够保证可见性,在CAS操作的时候不会出现问题。 - -```java -private static final Unsafe unsafe = Unsafe.getUnsafe(); -private static final long valueOffset; - -static { - try { - valueOffset = unsafe.objectFieldOffset - (AtomicInteger.class.getDeclaredField("value")); - } catch (Exception ex) { throw new Error(ex); } -} -``` - -可以看到最上面是和AQS采用了类似的机制,因为要使用CAS算法更新value的值,所以得先计算出value字段在对象中的偏移地址,CAS直接修改对应位置的内存即可(可见Unsafe类的作用巨大,很多的底层操作都要靠它来完成) - -接着我们来看自增操作是怎么在运行的: - -```java -public final int getAndIncrement() { - return unsafe.getAndAddInt(this, valueOffset, 1); -} -``` - -可以看到这里调用了`unsafe.getAndAddInt()`,套娃时间到,我们接着看看Unsafe里面写了什么: - -```java -public final int getAndAddInt(Object o, long offset, int delta) { //delta就是变化的值,++操作就是自增1 - int v; - do { - //volatile版本的getInt() - //能够保证可见性 - v = getIntVolatile(o, offset); - } while (!compareAndSwapInt(o, offset, v, v + delta)); //这里是开始cas替换int的值,每次都去拿最新的值去进行替换,如果成功则离开循环,不成功说明这个时候其他线程先修改了值,就进下一次循环再获取最新的值然后再cas一次,直到成功为止 - return v; -} -``` - -可以看到这是一个`do-while`循环,那么这个循环在做一个什么事情呢?感觉就和我们之前讲解的AQS队列中的机制差不多,也是采用自旋形式,来不断进行CAS操作,直到成功。 - -![image-20220308131536403](https://tva1.sinaimg.cn/large/e6c9d24egy1h02eg5qcfaj21pa0ekju1.jpg) - -可见,原子类底层也是采用了CAS算法来保证的原子性,包括`getAndSet`、`getAndAdd`等方法都是这样。原子类也直接提供了CAS操作方法,我们可以直接使用: - -```java -public static void main(String[] args) throws InterruptedException { - AtomicInteger integer = new AtomicInteger(10); - System.out.println(integer.compareAndSet(30, 20)); - System.out.println(integer.compareAndSet(10, 20)); - System.out.println(integer); -} -``` - -如果想以普通变量的方式来设定值,那么可以使用`lazySet()`方法,这样就不采用`volatile`的立即可见机制了。 - -```java -AtomicInteger integer = new AtomicInteger(1); -integer.lazySet(2); -``` - -除了基本类有原子类以外,基本类型的数组类型也有原子类: - -* AtomicIntegerArray:原子更新int数组 -* AtomicLongArray:原子更新long数组 -* AtomicReferenceArray:原子更新引用数组 - -其实原子数组和原子类型一样的,不过我们可以对数组内的元素进行原子操作: - -```java -public static void main(String[] args) throws InterruptedException { - AtomicIntegerArray array = new AtomicIntegerArray(new int[]{0, 4, 1, 3, 5}); - Runnable r = () -> { - for (int i = 0; i < 100000; i++) - array.getAndAdd(0, 1); - }; - new Thread(r).start(); - new Thread(r).start(); - TimeUnit.SECONDS.sleep(1); - System.out.println(array.get(0)); -} -``` - -在JDK8之后,新增了`DoubleAdder`和`LongAdder`,在高并发情况下,`LongAdder`的性能比`AtomicLong`的性能更好,主要体现在自增上,它的大致原理如下:在低并发情况下,和`AtomicLong`是一样的,对value值进行CAS操作,但是出现高并发的情况时,`AtomicLong`会进行大量的循环操作来保证同步,而`LongAdder`会将对value值的CAS操作分散为对数组`cells`中多个元素的CAS操作(内部维护一个Cell[] as数组,每个Cell里面有一个初始值为0的long型变量,在高并发时会进行分散CAS,就是不同的线程可以对数组中不同的元素进行CAS自增,这样就避免了所有线程都对同一个值进行CAS),只需要最后再将结果加起来即可。 - -![image-20220308141517668](https://tva1.sinaimg.cn/large/e6c9d24egy1h02g67t42fj21ps0lan19.jpg) - -使用如下: - -```java -public static void main(String[] args) throws InterruptedException { - LongAdder adder = new LongAdder(); - Runnable r = () -> { - for (int i = 0; i < 100000; i++) - adder.add(1); - }; - for (int i = 0; i < 100; i++) - new Thread(r).start(); //100个线程 - TimeUnit.SECONDS.sleep(1); - System.out.println(adder.sum()); //最后求和即可 -} -``` - -由于底层源码比较复杂,这里就不做讲解了。两者的性能对比(这里用到了CountDownLatch,建议学完之后再来看): - -```java -public class Main { - public static void main(String[] args) throws InterruptedException { - System.out.println("使用AtomicLong的时间消耗:"+test2()+"ms"); - System.out.println("使用LongAdder的时间消耗:"+test1()+"ms"); - } - - private static long test1() throws InterruptedException { - CountDownLatch latch = new CountDownLatch(100); - LongAdder adder = new LongAdder(); - long timeStart = System.currentTimeMillis(); - Runnable r = () -> { - for (int i = 0; i < 100000; i++) - adder.add(1); - latch.countDown(); - }; - for (int i = 0; i < 100; i++) - new Thread(r).start(); - latch.await(); - return System.currentTimeMillis() - timeStart; - } - - private static long test2() throws InterruptedException { - CountDownLatch latch = new CountDownLatch(100); - AtomicLong atomicLong = new AtomicLong(); - long timeStart = System.currentTimeMillis(); - Runnable r = () -> { - for (int i = 0; i < 100000; i++) - atomicLong.incrementAndGet(); - latch.countDown(); - }; - for (int i = 0; i < 100; i++) - new Thread(r).start(); - latch.await(); - return System.currentTimeMillis() - timeStart; - } -} -``` - -除了对基本数据类型支持原子操作外,对于引用类型,也是可以实现原子操作的: - -```java -public static void main(String[] args) throws InterruptedException { - String a = "Hello"; - String b = "World"; - AtomicReference reference = new AtomicReference<>(a); - reference.compareAndSet(a, b); - System.out.println(reference.get()); -} -``` - -JUC还提供了字段原子更新器,可以对类中的某个指定字段进行原子操作(注意字段必须添加volatile关键字): - -```java -public class Main { - public static void main(String[] args) throws InterruptedException { - Student student = new Student(); - AtomicIntegerFieldUpdater fieldUpdater = - AtomicIntegerFieldUpdater.newUpdater(Student.class, "age"); - System.out.println(fieldUpdater.incrementAndGet(student)); - } - - public static class Student{ - volatile int age; - } -} -``` - -了解了这么多原子类,是不是感觉要实现保证原子性的工作更加轻松了? - -### ABA问题及解决方案 - -我们来想象一下这种场景: - -![image-20220308150840321](https://tva1.sinaimg.cn/large/e6c9d24egy1h02hpquocrj213i0c8myf.jpg) - -线程1和线程2同时开始对`a`的值进行CAS修改,但是线程1的速度比较快,将a的值修改为2之后紧接着又修改回1,这时线程2才开始进行判断,发现a的值是1,所以CAS操作成功。 - -很明显,这里的1已经不是一开始的那个1了,而是被重新赋值的1,这也是CAS操作存在的问题(无锁虽好,但是问题多多),它只会机械地比较当前值是不是预期值,但是并不会关心当前值是否被修改过,这种问题称之为`ABA`问题。 - -那么如何解决这种`ABA`问题呢,JUC提供了带版本号的引用类型,只要每次操作都记录一下版本号,并且版本号不会重复,那么就可以解决ABA问题了: - -```java -public static void main(String[] args) throws InterruptedException { - String a = "Hello"; - String b = "World"; - AtomicStampedReference reference = new AtomicStampedReference<>(a, 1); //在构造时需要指定初始值和对应的版本号 - reference.attemptStamp(a, 2); //可以中途对版本号进行修改,注意要填写当前的引用对象 - System.out.println(reference.compareAndSet(a, b, 2, 3)); //CAS操作时不仅需要提供预期值和修改值,还要提供预期版本号和新的版本号 -} -``` - -至此,有关原子类的讲解就到这里。 - -*** - -## 并发容器 - -简单的讲完了,又该讲难一点的了。 - -**注意:**本版块的重点在于探究并发容器是如何利用锁机制和算法实现各种丰富功能的,我们会忽略一些常规功能的实现细节(比如链表如何插入元素删除元素),而更关注并发容器应对并发场景算法上的实现(比如在多线程环境下的插入操作是按照什么规则进行的) - -在单线程模式下,集合类提供的容器可以说是非常方便了,几乎我们每个项目中都能或多或少的用到它们,我们在JavaSE阶段,为各位讲解了各个集合类的实现原理,我们了解了链表、顺序表、哈希表等数据结构,那么,在多线程环境下,这些数据结构还能正常工作吗? - -### 传统容器线程安全吗 - -我们来测试一下,100个线程同时向ArrayList中添加元素会怎么样: - -```java -public class Main { - public static void main(String[] args) { - List list = new ArrayList<>(); - Runnable r = () -> { - for (int i = 0; i < 100; i++) - list.add("lbwnb"); - }; - for (int i = 0; i < 100; i++) - new Thread(r).start(); - TimeUnit.SECONDS.sleep(1); - System.out.println(list.size()); - } -} -``` - -不出意外的话,肯定是会报错的: - -``` -Exception in thread "Thread-0" java.lang.ArrayIndexOutOfBoundsException: 73 - at java.util.ArrayList.add(ArrayList.java:465) - at com.test.Main.lambda$main$0(Main.java:13) - at java.lang.Thread.run(Thread.java:750) -Exception in thread "Thread-19" java.lang.ArrayIndexOutOfBoundsException: 1851 - at java.util.ArrayList.add(ArrayList.java:465) - at com.test.Main.lambda$main$0(Main.java:13) - at java.lang.Thread.run(Thread.java:750) -9773 -``` - -那么我们来看看报的什么错,从栈追踪信息可以看出,是add方法出现了问题: - -```java -public boolean add(E e) { - ensureCapacityInternal(size + 1); // Increments modCount!! - elementData[size++] = e; //这一句出现了数组越界 - return true; -} -``` - -也就是说,同一时间其他线程也在疯狂向数组中添加元素,那么这个时候有可能在`ensureCapacityInternal`(确认容量足够)执行之后,`elementData[size++] = e;`执行之前,其他线程插入了元素,导致size的值超出了数组容量。这些在单线程的情况下不可能发生的问题,在多线程下就慢慢出现了。 - -我们再来看看比较常用的HashMap呢? - -```java -public static void main(String[] args) throws InterruptedException { - Map map = new HashMap<>(); - for (int i = 0; i < 100; i++) { - int finalI = i; - new Thread(() -> { - for (int j = 0; j < 100; j++) - map.put(finalI * 1000 + j, "lbwnb"); - }).start(); - } - TimeUnit.SECONDS.sleep(2); - System.out.println(map.size()); -} -``` - -经过测试发现,虽然没有报错,但是最后的结果并不是我们期望的那样,实际上它还有可能导致Entry对象出现环状数据结构,引起死循环。 - -所以,在多线程环境下,要安全地使用集合类,我们得找找解决方案了。 - -### 并发容器介绍 - -怎么才能解决并发情况下的容器问题呢?我们首先想到的肯定是给方法前面加个`synchronzed`关键字,这样总不会抢了吧,在之前我们可以使用Vector或是Hashtable来解决,但是它们的效率实在是太低了,完全依靠锁来解决问题,因此现在已经很少再使它们了,这里也不会再去进行讲解。 - -JUC提供了专用于并发场景下的容器,比如我们刚刚使用的ArrayList,在多线程环境下是没办法使用的,我们可以将其替换为JUC提供的多线程专用集合类: - -```java -public static void main(String[] args) throws InterruptedException { - List list = new CopyOnWriteArrayList<>(); //这里使用CopyOnWriteArrayList来保证线程安全 - Runnable r = () -> { - for (int i = 0; i < 100; i++) - list.add("lbwnb"); - }; - for (int i = 0; i < 100; i++) - new Thread(r).start(); - TimeUnit.SECONDS.sleep(1); - System.out.println(list.size()); -} -``` - -我们发现,使用了`CopyOnWriteArrayList`之后,再没出现过上面的问题。 - -那么它是如何实现的呢,我们先来看看它是如何进行`add()`操作的: - -```java -public boolean add(E e) { - final ReentrantLock lock = this.lock; - lock.lock(); //直接加锁,保证同一时间只有一个线程进行添加操作 - try { - Object[] elements = getArray(); //获取当前存储元素的数组 - int len = elements.length; - Object[] newElements = Arrays.copyOf(elements, len + 1); //直接复制一份数组 - newElements[len] = e; //修改复制出来的数组 - setArray(newElements); //将元素数组设定为复制出来的数组 - return true; - } finally { - lock.unlock(); - } -} -``` - -可以看到添加操作是直接上锁,并且会先拷贝一份当前存放元素的数组,然后对数组进行修改,再将此数组替换(CopyOnWrite)接着我们来看读操作: - -```java -public E get(int index) { - return get(getArray(), index); -} -``` - -因此,`CopyOnWriteArrayList`对于读操作不加锁,而对于写操作是加锁的,类似于我们前面讲解的读写锁机制,这样就可以保证不丢失读性能的情况下,写操作不会出现问题。 - -接着我们来看对于HashMap的并发容器`ConcurrentHashMap`: - -```java -public static void main(String[] args) throws InterruptedException { - Map map = new ConcurrentHashMap<>(); - for (int i = 0; i < 100; i++) { - int finalI = i; - new Thread(() -> { - for (int j = 0; j < 100; j++) - map.put(finalI * 100 + j, "lbwnb"); - }).start(); - } - TimeUnit.SECONDS.sleep(1); - System.out.println(map.size()); -} -``` - -可以看到这里的ConcurrentHashMap就没有出现之前HashMap的问题了。因为线程之间会争抢同一把锁,我们之前在讲解LongAdder的时候学习到了一种压力分散思想,既然每个线程都想抢锁,那我就干脆多搞几把锁,让你们每个人都能拿到,这样就不会存在等待的问题了,而JDK7之前,ConcurrentHashMap的原理也比较类似,它将所有数据分为一段一段地存储,先分很多段出来,每一段都给一把锁,当一个线程占锁访问时,只会占用其中一把锁,也就是仅仅锁了一小段数据,而其他段的数据依然可以被其他线程正常访问。 - -![image-20220308165304048](https://tva1.sinaimg.cn/large/e6c9d24egy1h02kqcrfhcj21fk0fk75i.jpg) - -这里我们重点讲解JDK8之后它是怎么实现的,它采用了CAS算法配合锁机制实现,我们先来回顾一下JDK8下的HashMap是什么样的结构: - -![img](https://img-blog.csdnimg.cn/img_convert/3ad05990ed9e29801b1992030c030a00.png) - -HashMap就是利用了哈希表,哈希表的本质其实就是一个用于存放后续节点的头结点的数组,数组里面的每一个元素都是一个头结点(也可以说就是一个链表),当要新插入一个数据时,会先计算该数据的哈希值,找到数组下标,然后创建一个新的节点,添加到对应的链表后面。当链表的长度达到8时,会自动将链表转换为红黑树,这样能使得原有的查询效率大幅度降低!当使用红黑树之后,我们就可以利用二分搜索的思想,快速地去寻找我们想要的结果,而不是像链表一样挨个去看。 - -又是基础不牢地动山摇环节,由于ConcurrentHashMap的源码比较复杂,所以我们先从最简单的构造方法开始下手: - -![image-20220308214006830](https://tva1.sinaimg.cn/large/e6c9d24ely1h02t130fslj222h0u0k1b.jpg) - -我们发现,它的构造方法和HashMap的构造方法有很大的出入,但是大体的结构和HashMap是差不多的,也是维护了一个哈希表,并且哈希表中存放的是链表或是红黑树,所以我们直接来看`put()`操作是如何实现的,只要看明白这个,基本上就懂了: - -```java -public V put(K key, V value) { - return putVal(key, value, false); -} - -//有点小乱,如果看着太乱,可以在IDEA中折叠一下代码块,不然有点难受 -final V putVal(K key, V value, boolean onlyIfAbsent) { - if (key == null || value == null) throw new NullPointerException(); //键值不能为空,基操 - int hash = spread(key.hashCode()); //计算键的hash值,用于确定在哈希表中的位置 - int binCount = 0; //一会用来记录链表长度的,忽略 - for (Node[] tab = table;;) { //无限循环,而且还是并发包中的类,盲猜一波CAS自旋锁 - Node f; int n, i, fh; - if (tab == null || (n = tab.length) == 0) - tab = initTable(); //如果数组(哈希表)为空肯定是要进行初始化的,然后再重新进下一轮循环 - else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { //如果哈希表该位置为null,直接CAS插入结点作为头结即可(注意这里会将f设置当前哈希表位置上的头结点) - if (casTabAt(tab, i, null, - new Node(hash, key, value, null))) - break; // 如果CAS成功,直接break结束put方法,失败那就继续下一轮循环 - } else if ((fh = f.hash) == MOVED) //头结点哈希值为-1,这里只需要知道是因为正在扩容即可 - tab = helpTransfer(tab, f); //帮助进行迁移,完事之后再来下一次循环 - else { //特殊情况都完了,这里就该是正常情况了, - V oldVal = null; - synchronized (f) { //在前面的循环中f肯定是被设定为了哈希表某个位置上的头结点,这里直接把它作为锁加锁了,防止同一时间其他线程也在操作哈希表中这个位置上的链表或是红黑树 - if (tabAt(tab, i) == f) { - if (fh >= 0) { //头结点的哈希值大于等于0说明是链表,下面就是针对链表的一些列操作 - ...实现细节略 - } else if (f instanceof TreeBin) { //肯定不大于0,肯定也不是-1,还判断是不是TreeBin,所以不用猜了,肯定是红黑树,下面就是针对红黑树的情况进行操作 - //在ConcurrentHashMap并不是直接存储的TreeNode,而是TreeBin - ...实现细节略 - } - } - } - //根据链表长度决定是否要进化为红黑树 - if (binCount != 0) { - if (binCount >= TREEIFY_THRESHOLD) - treeifyBin(tab, i); //注意这里只是可能会进化为红黑树,如果当前哈希表的长度小于64,它会优先考虑对哈希表进行扩容 - if (oldVal != null) - return oldVal; - break; - } - } - } - addCount(1L, binCount); - return null; -} -``` - -怎么样,是不是感觉看着挺复杂,其实也还好,总结一下就是: - -![image-20220308230825627](https://tva1.sinaimg.cn/large/e6c9d24ely1h02vkx608bj21eo0iwad0.jpg) - -我们接着来看看`get()`操作: - -```java -public V get(Object key) { - Node[] tab; Node e, p; int n, eh; K ek; - int h = spread(key.hashCode()); //计算哈希值 - if ((tab = table) != null && (n = tab.length) > 0 && - (e = tabAt(tab, (n - 1) & h)) != null) { - // 如果头结点就是我们要找的,那直接返回值就行了 - if ((eh = e.hash) == h) { - if ((ek = e.key) == key || (ek != null && key.equals(ek))) - return e.val; - } - //要么是正在扩容,要么就是红黑树,负数只有这两种情况 - else if (eh < 0) - return (p = e.find(h, key)) != null ? p.val : null; - //确认无误,肯定在列表里,开找 - while ((e = e.next) != null) { - if (e.hash == h && - ((ek = e.key) == key || (ek != null && key.equals(ek)))) - return e.val; - } - } - //没找到只能null了 - return null; -} -``` - -综上,ConcurrentHashMap的put操作,实际上是对哈希表上的所有头结点元素分别加锁,理论上来说哈希表的长度很大程度上决定了ConcurrentHashMap在同一时间能够处理的线程数量,这也是为什么`treeifyBin()`会优先考虑为哈希表进行扩容的原因。显然,这种加锁方式比JDK7的分段锁机制性能更好。 - -其实这里也只是简单地介绍了一下它的运行机制,ConcurrentHashMap真正的难点在于扩容和迁移操作,我们主要了解的是他的并发执行机制,有关它的其他实现细节,这里暂时不进行讲解。 - -### 阻塞队列 - -除了我们常用的容器类之外,JUC还提供了各种各样的阻塞队列,用于不同的工作场景。 - -阻塞队列本身也是队列,但是它是适用于多线程环境下的,基于ReentrantLock实现的,它的接口定义如下: - -```java -public interface BlockingQueue extends Queue { - boolean add(E e); - - //入队,如果队列已满,返回false否则返回true(非阻塞) - boolean offer(E e); - - //入队,如果队列已满,阻塞线程直到能入队为止 - void put(E e) throws InterruptedException; - - //入队,如果队列已满,阻塞线程直到能入队或超时、中断为止,入队成功返回true否则false - boolean offer(E e, long timeout, TimeUnit unit) - throws InterruptedException; - - //出队,如果队列为空,阻塞线程直到能出队为止 - E take() throws InterruptedException; - - //出队,如果队列为空,阻塞线程直到能出队超时、中断为止,出队成功正常返回,否则返回null - E poll(long timeout, TimeUnit unit) - throws InterruptedException; - - //返回此队列理想情况下(在没有内存或资源限制的情况下)可以不阻塞地入队的数量,如果没有限制,则返回 Integer.MAX_VALUE - int remainingCapacity(); - - boolean remove(Object o); - - public boolean contains(Object o); - - //一次性从BlockingQueue中获取所有可用的数据对象(还可以指定获取数据的个数) - int drainTo(Collection c); - - int drainTo(Collection c, int maxElements); -``` - -比如现在有一个容量为3的阻塞队列,这个时候一个线程`put`向其添加了三个元素,第二个线程接着`put`向其添加三个元素,那么这个时候由于容量已满,会直接被阻塞,而这时第三个线程从队列中取走2个元素,线程二停止阻塞,先丢两个进去,还有一个还是进不去,所以说继续阻塞。 - -![image-20220309165644403](https://tva1.sinaimg.cn/large/e6c9d24egy1h03qghwg2nj21sc0gawhb.jpg) - -利用阻塞队列,我们可以轻松地实现消费者和生产者模式,还记得我们在JavaSE中的实战吗? - -> 所谓的生产者消费者模型,是通过一个容器来解决生产者和消费者的强耦合问题。通俗的讲,就是生产者在不断的生产,消费者也在不断的消费,可是消费者消费的产品是生产者生产的,这就必然存在一个中间容器,我们可以把这个容器想象成是一个货架,当货架空的时候,生产者要生产产品,此时消费者在等待生产者往货架上生产产品,而当货架有货物的时候,消费者可以从货架上拿走商品,生产者此时等待货架出现空位,进而补货,这样不断的循环。 - -通过多线程编程,来模拟一个餐厅的2个厨师和3个顾客,假设厨师炒出一个菜的时间为3秒,顾客吃掉菜品的时间为4秒,窗口上只能放一个菜。 - -我们来看看,使用阻塞队列如何实现,这里我们就使用`ArrayBlockingQueue`实现类: - -```java -public class Main { - public static void main(String[] args) throws InterruptedException { - BlockingQueue queue = new ArrayBlockingQueue<>(1); - Runnable supplier = () -> { - while (true){ - try { - String name = Thread.currentThread().getName(); - System.err.println(time()+"生产者 "+name+" 正在准备餐品..."); - TimeUnit.SECONDS.sleep(3); - System.err.println(time()+"生产者 "+name+" 已出餐!"); - queue.put(new Object()); - } catch (InterruptedException e) { - e.printStackTrace(); - break; - } - } - }; - Runnable consumer = () -> { - while (true){ - try { - String name = Thread.currentThread().getName(); - System.out.println(time()+"消费者 "+name+" 正在等待出餐..."); - queue.take(); - System.out.println(time()+"消费者 "+name+" 取到了餐品。"); - TimeUnit.SECONDS.sleep(4); - System.out.println(time()+"消费者 "+name+" 已经将饭菜吃完了!"); - } catch (InterruptedException e) { - e.printStackTrace(); - break; - } - } - }; - for (int i = 0; i < 2; i++) new Thread(supplier, "Supplier-"+i).start(); - for (int i = 0; i < 3; i++) new Thread(consumer, "Consumer-"+i).start(); - } - - private static String time(){ - SimpleDateFormat format = new SimpleDateFormat("HH:mm:ss"); - return "["+format.format(new Date()) + "] "; - } -} -``` - -可以看到,阻塞队列在多线程环境下的作用是非常明显的,算上ArrayBlockingQueue,一共有三种常用的阻塞队列: - -* ArrayBlockingQueue:有界带缓冲阻塞队列(就是队列是有容量限制的,装满了肯定是不能再装的,只能阻塞,数组实现) -* SynchronousQueue:无缓冲阻塞队列(相当于没有容量的ArrayBlockingQueue,因此只有阻塞的情况) -* LinkedBlockingQueue:无界带缓冲阻塞队列(没有容量限制,也可以限制容量,也会阻塞,链表实现) - -这里我们以ArrayBlockingQueue为例进行源码解读,我们先来看看构造方法: - -```java -final ReentrantLock lock; - -private final Condition notEmpty; - -private final Condition notFull; - -public ArrayBlockingQueue(int capacity, boolean fair) { - if (capacity <= 0) - throw new IllegalArgumentException(); - this.items = new Object[capacity]; - lock = new ReentrantLock(fair); //底层采用锁机制保证线程安全性,这里我们可以选择使用公平锁或是非公平锁 - notEmpty = lock.newCondition(); //这里创建了两个Condition(都属于lock)一会用于入队和出队的线程阻塞控制 - notFull = lock.newCondition(); -} -``` - -接着我们来看`put`和`offer`方法是如何实现的: - -```java -public boolean offer(E e) { - checkNotNull(e); - final ReentrantLock lock = this.lock; //可以看到这里也是使用了类里面的ReentrantLock进行加锁操作 - lock.lock(); //保证同一时间只有一个线程进入 - try { - if (count == items.length) //直接看看队列是否已满,如果没满则直接入队,如果已满则返回false - return false; - else { - enqueue(e); - return true; - } - } finally { - lock.unlock(); - } -} - -public void put(E e) throws InterruptedException { - checkNotNull(e); - final ReentrantLock lock = this.lock; //同样的,需要进行加锁操作 - lock.lockInterruptibly(); //注意这里是可以响应中断的 - try { - while (count == items.length) - notFull.await(); //可以看到当队列已满时会直接挂起当前线程,在其他线程出队操作时会被唤醒 - enqueue(e); //直到队列有空位才将线程入队 - } finally { - lock.unlock(); - } -} -``` - -```java -private E dequeue() { - // assert lock.getHoldCount() == 1; - // assert items[takeIndex] != null; - final Object[] items = this.items; - @SuppressWarnings("unchecked") - E x = (E) items[takeIndex]; - items[takeIndex] = null; - if (++takeIndex == items.length) - takeIndex = 0; - count--; - if (itrs != null) - itrs.elementDequeued(); - notFull.signal(); //出队操作会调用notFull的signal方法唤醒被挂起处于等待状态的线程 - return x; -} -``` - -接着我们来看出队操作: - -```java -public E poll() { - final ReentrantLock lock = this.lock; - lock.lock(); //出队同样进行加锁操作,保证同一时间只能有一个线程执行 - try { - return (count == 0) ? null : dequeue(); //如果队列不为空则出队,否则返回null - } finally { - lock.unlock(); - } -} - -public E take() throws InterruptedException { - final ReentrantLock lock = this.lock; - lock.lockInterruptibly(); //可以响应中断进行加锁 - try { - while (count == 0) - notEmpty.await(); //和入队相反,也是一直等直到队列中有元素之后才可以出队,在入队时会唤醒此线程 - return dequeue(); - } finally { - lock.unlock(); - } -} -``` - -```java -private void enqueue(E x) { - // assert lock.getHoldCount() == 1; - // assert items[putIndex] == null; - final Object[] items = this.items; - items[putIndex] = x; - if (++putIndex == items.length) - putIndex = 0; - count++; - notEmpty.signal(); //对notEmpty的signal唤醒操作 -} -``` - -可见,如果各位对锁的使用非常熟悉的话,那么在阅读这些源码的时候,就会非常轻松了。 - -接着我们来看一个比较特殊的队列SynchronousQueue,它没有任何容量,也就是说正常情况下出队必须和入队操作成对出现,我们先来看它的内部,可以看到内部有一个抽象类Transferer,它定义了一个`transfer`方法: - -```java -abstract static class Transferer { - /** - * 可以是put也可以是take操作 - * - * @param e 如果不是空,即作为生产者,那么表示会将传入参数元素e交给消费者 - * 如果为空,即作为消费者,那么表示会从生产者那里得到一个元素e并返回 - * @param 是否可以超时 - * @param 超时时间 - * @return 不为空就是从生产者那里返回的,为空表示要么被中断要么超时。 - */ - abstract E transfer(E e, boolean timed, long nanos); -} -``` - -乍一看,有点迷惑,难不成还要靠这玩意去实现put和take操作吗?实际上它是直接以生产者消费者模式进行的,由于不需要依靠任何容器结构来暂时存放数据,所以我们可以直接通过`transfer`方法来对生产者和消费者之间的数据进行传递。 - -比如一个线程put一个新的元素进入,这时如果没有其他线程调用take方法获取元素,那么会持续被阻塞,直到有线程取出元素,而`transfer`正是需要等生产者消费者双方都到齐了才能进行交接工作,单独只有其中一方都需要进行等待。 - -```java -public void put(E e) throws InterruptedException { - if (e == null) throw new NullPointerException(); //判空 - if (transferer.transfer(e, false, 0) == null) { //直接使用transfer方法进行数据传递 - Thread.interrupted(); //为空表示要么被中断要么超时 - throw new InterruptedException(); - } -} -``` - -它在公平和非公平模式下,有两个实现,这里我们来看公平模式下的SynchronousQueue是如何实现的: - -```java -static final class TransferQueue extends Transferer { - //头结点(头结点仅作为头结点,后续节点才是真正等待的线程节点) - transient volatile QNode head; - //尾结点 - transient volatile QNode tail; - - /** 节点有生产者和消费者角色之分 */ - static final class QNode { - volatile QNode next; // 后继节点 - volatile Object item; // 存储的元素 - volatile Thread waiter; // 处于等待的线程,和之前的AQS一样的思路,每个线程等待的时候都会被封装为节点 - final boolean isData; // 是生产者节点还是消费者节点 -``` - -公平模式下,Transferer的实现是TransferQueue,是以先进先出的规则的进行的,内部有一个QNode类来保存等待的线程。 - -好了,我们直接上`transfer()`方法的实现(这里再次提醒各位,多线程环境下的源码分析和单线程的分析不同,我们需要时刻关注当前代码块的加锁状态,如果没有加锁,一定要具有多线程可能会同时运行的意识,这个意识在以后你自己处理多线程问题伴随着你,才能保证你的思路在多线程环境下是正确的): - -```java -E transfer(E e, boolean timed, long nanos) { //注意这里面没加锁,肯定会多个线程之间竞争 - QNode s = null; - boolean isData = (e != null); //e为空表示消费者,不为空表示生产者 - - for (;;) { - QNode t = tail; - QNode h = head; - if (t == null || h == null) // 头结点尾结点任意为空(但是在构造的时候就已经不是空了) - continue; // 自旋 - - if (h == t || t.isData == isData) { // 头结点等于尾结点表示队列中只有一个头结点,肯定是空,或者尾结点角色和当前节点一样,这两种情况下,都需要进行入队操作 - QNode tn = t.next; - if (t != tail) // 如果这段时间内t被其他线程修改了,如果是就进下一轮循环重新来 - continue; - if (tn != null) { // 继续校验是否为队尾,如果tn不为null,那肯定是其他线程改了队尾,可以进下一轮循环重新来了 - advanceTail(t, tn); // CAS将新的队尾节点设置为tn,成不成功都无所谓,反正这一轮肯定没戏了 - continue; - } - if (timed && nanos <= 0) // 超时返回null - return null; - if (s == null) - s = new QNode(e, isData); //构造当前结点,准备加入等待队列 - if (!t.casNext(null, s)) // CAS添加当前节点为尾结点的下一个,如果失败肯定其他线程又抢先做了,直接进下一轮循环重新来 - continue; - - advanceTail(t, s); // 上面的操作基本OK了,那么新的队尾元素就修改为s - Object x = awaitFulfill(s, e, timed, nanos); //开始等待s所对应的消费者或是生产者进行交接,比如s现在是生产者,那么它就需要等到一个消费者的到来才会继续(这个方法会先进行自旋等待匹配,如果自旋一定次数后还是没有匹配成功,那么就挂起) - if (x == s) { // 如果返回s本身说明等待状态下被取消 - clean(t, s); - return null; - } - - if (!s.isOffList()) { // 如果s操作完成之后没有离开队列,那么这里将其手动丢弃 - advanceHead(t, s); // 将s设定为新的首节点(注意头节点仅作为头结点,并非处于等待的线程节点) - if (x != null) // 删除s内的其他信息 - s.item = s; - s.waiter = null; - } - return (x != null) ? (E)x : e; //假如当前是消费者,直接返回x即可,x就是从生产者那里拿来的元素 - - } else { // 这种情况下就是与队列中结点类型匹配的情况了(注意队列要么为空要么只会存在一种类型的节点,因为一旦出现不同类型的节点马上会被交接掉) - QNode m = h.next; // 获取头结点的下一个接口,准备进行交接工作 - if (t != tail || m == null || h != head) - continue; // 判断其他线程是否先修改,如果修改过那么开下一轮 - - Object x = m.item; - if (isData == (x != null) || // 判断节点类型,如果是相同的操作,那肯定也是有问题的 - x == m || // 或是当前操作被取消 - !m.casItem(x, e)) { // 上面都不是?那么最后再进行CAS替换m中的元素,成功表示交接成功,失败就老老实实重开吧 - advanceHead(h, m); // dequeue and retry - continue; - } - - advanceHead(h, m); // 成功交接,新的头结点可以改为m了,原有的头结点直接不要了 - LockSupport.unpark(m.waiter); // m中的等待交接的线程可以继续了,已经交接完成 - return (x != null) ? (E)x : e; // 同上,该返回什么就返回什么 - } - } -} -``` - -所以,总结为以下流程: - -![image-20220314002203511](https://tva1.sinaimg.cn/large/e6c9d24ely1h08pt2hn9rj21rw0mojw7.jpg) - -对于非公平模式下的SynchronousQueue,则是采用的栈结构来存储等待节点,但是思路也是与这里的一致,需要等待并进行匹配操作,各位如果感兴趣可以继续了解一下非公平模式下的SynchronousQueue实现。 - -在JDK7的时候,基于SynchronousQueue产生了一个更强大的TransferQueue,它保留了SynchronousQueue的匹配交接机制,并且与等待队列进行融合。 - -我们知道,SynchronousQueue并没有使用锁,而是采用CAS操作保证生产者与消费者的协调,但是它没有容量,而LinkedBlockingQueue虽然是有容量且无界的,但是内部基本都是基于锁实现的,性能并不是很好,这时,我们就可以将它们各自的优点单独拿出来,揉在一起,就成了性能更高的LinkedTransferQueue - -```java -public static void main(String[] args) throws InterruptedException { - LinkedTransferQueue queue = new LinkedTransferQueue<>(); - queue.put("1"); //插入时,会先检查是否有其他线程等待获取,如果是,直接进行交接,否则插入到存储队列中 - queue.put("2"); //不会像SynchronousQueue那样必须等一个匹配的才可以 - queue.forEach(System.out::println); //直接打印所有的元素,这在SynchronousQueue下只能是空,因为单独的入队或出队操作都会被阻塞 -} -``` - -相比 `SynchronousQueue` ,它多了一个可以存储的队列,我们依然可以像阻塞队列那样获取队列中所有元素的值,简单来说,`LinkedTransferQueue`其实就是一个多了存储队列的`SynchronousQueue`。 - -接着我们来了解一些其他的队列: - -* PriorityBlockingQueue - 是一个支持优先级的阻塞队列,元素的获取顺序按优先级决定。 -* DelayQueue - 它能够实现延迟获取元素,同样支持优先级。 - -我们先来看优先级阻塞队列: - -```java -public static void main(String[] args) throws InterruptedException { - PriorityBlockingQueue queue = - new PriorityBlockingQueue<>(10, Integer::compare); //可以指定初始容量(可扩容)和优先级比较规则,这里我们使用升序 - queue.add(3); - queue.add(1); - queue.add(2); - System.out.println(queue); //注意保存顺序并不会按照优先级排列,所以可以看到结果并不是排序后的结果 - System.out.println(queue.poll()); //但是出队顺序一定是按照优先级进行的 - System.out.println(queue.poll()); - System.out.println(queue.poll()); -} -``` - -我们的重点是DelayQueue,它能实现延时出队,也就是说当一个元素插入后,如果没有超过一定时间,那么是无法让此元素出队的。 - -```java -public class DelayQueue extends AbstractQueue - implements BlockingQueue { -``` - -可以看到此类只接受Delayed的实现类作为元素: - -```java -public interface Delayed extends Comparable { //注意这里继承了Comparable,它支持优先级 - - //获取剩余等待时间,正数表示还需要进行等待,0或负数表示等待结束 - long getDelay(TimeUnit unit); -} -``` - -这里我们手动实现一个: - -```java -private static class Test implements Delayed { - private final long time; //延迟时间,这里以毫秒为单位 - private final int priority; - private final long startTime; - private final String data; - - private Test(long time, int priority, String data) { - this.time = TimeUnit.SECONDS.toMillis(time); //秒转换为毫秒 - this.priority = priority; - this.startTime = System.currentTimeMillis(); //这里我们以毫秒为单位 - this.data = data; - } - - @Override - public long getDelay(TimeUnit unit) { - long leftTime = time - (System.currentTimeMillis() - startTime); //计算剩余时间 = 设定时间 - 已度过时间(= 当前时间 - 开始时间) - return unit.convert(leftTime, TimeUnit.MILLISECONDS); //注意进行单位转换,单位由队列指定(默认是纳秒单位) - } - - @Override - public int compareTo(Delayed o) { - if(o instanceof Test) - return priority - ((Test) o).priority; //优先级越小越优先 - return 0; - } - - @Override - public String toString() { - return data; - } -} -``` - -接着我们在主方法中尝试使用: - -```java -public static void main(String[] args) throws InterruptedException { - DelayQueue queue = new DelayQueue<>(); - queue.add(new Test(1, 2, "2号")); //1秒钟延时 - queue.add(new Test(3, 1, "1号")); //1秒钟延时,优先级最高 - - System.out.println(queue.take()); //注意出队顺序是依照优先级来的,即使一个元素已经可以出队了,依然需要等待优先级更高的元素到期 - System.out.println(queue.take()); -} -``` - -我们来研究一下DelayQueue是如何实现的,首先来看`add()`方法: - -```java -public boolean add(E e) { - return offer(e); -} - -public boolean offer(E e) { - final ReentrantLock lock = this.lock; - lock.lock(); - try { - q.offer(e); //注意这里是向内部维护的一个优先级队列添加元素,并不是DelayQueue本身存储元素 - if (q.peek() == e) { //如果入队后队首就是当前元素,那么直接进行一次唤醒操作(因为有可能之前就有其他线程等着take了) - leader = null; - available.signal(); - } - return true; - } finally { - lock.unlock(); - } -} - -public void put(E e) { - offer(e); -} -``` - -可以看到无论是哪种入队操作,都会加锁进行,属于常规操作。我们接着来看`take()`方法: - -```java -public E take() throws InterruptedException { - final ReentrantLock lock = this.lock; //出队也要先加锁,基操 - lock.lockInterruptibly(); - try { - for (;;) { //无限循环,常规操作 - E first = q.peek(); //获取队首元素 - if (first == null) //如果为空那肯定队列为空,先等着吧,等有元素进来 - available.await(); - else { - long delay = first.getDelay(NANOSECONDS); //获取延迟,这里传入的时间单位是纳秒 - if (delay <= 0) - return q.poll(); //如果获取到延迟时间已经小于0了,那说明ok,可以直接出队返回 - first = null; - if (leader != null) //这里用leader来减少不必要的等待时间,如果不是null那说明有线程在等待,为null说明没有线程等待 - available.await(); //如果其他线程已经在等元素了,那么当前线程直接进永久等待状态 - else { - Thread thisThread = Thread.currentThread(); - leader = thisThread; //没有线程等待就将leader设定为当前线程 - try { - available.awaitNanos(delay); //获取到的延迟大于0,那么就需要等待延迟时间,再开始下一次获取 - } finally { - if (leader == thisThread) - leader = null; - } - } - } - } - } finally { - if (leader == null && q.peek() != null) - available.signal(); //当前take结束之后唤醒一个其他永久等待状态下的线程 - lock.unlock(); //解锁,完事 - } -} -``` - -到此,有关并发容器的讲解就到这里。 - -下一章我们会继续讲解线程池以及并发工具类。 \ No newline at end of file diff --git a/青空笔记/JVM笔记/JVM笔记(一).md b/青空笔记/JVM笔记/JVM笔记(一).md deleted file mode 100644 index 1a88719..0000000 --- a/青空笔记/JVM笔记/JVM笔记(一).md +++ /dev/null @@ -1,1031 +0,0 @@ -# 走进JVM - -JVM相对于Java应用层的学习难度更大,**开篇推荐掌握的预备知识:**C/C++(关键)、微机原理与接口技术、计算机组成原理、操作系统、数据结构与算法、编译原理(不推荐刚学完JavaSE的同学学习),如果没有掌握推荐的一半以上的预备知识,可能学习起来会比较吃力。 - -**本套课程中需要用到的开发工具:**CLion、IDEA、Jetbrains Gateway - -此阶段,我们需要深入探讨Java的底层执行原理,了解Java程序运行的本质。开始之前,推荐各位都入手一本《深入理解Java虚拟机 第三版》这本书对于JVM的讲述非常地详细: - -![点击查看图片来源](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimages-cn.ssl-images-amazon.cn%2Fimages%2FI%2F81zGZfnLdwL.__BG0%2C0%2C0%2C0_FMpng_AC_UL320_SR250%2C320_.jpg&refer=http%3A%2F%2Fimages-cn.ssl-images-amazon.cn&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1645933645&t=1f5da62f1510b166c33f05b94a830b48) - -我们在JavaSE阶段的开篇就进行介绍了,我们的Java程序之所以能够实现跨平台,本质就是因为它是运行在虚拟机之上的,而不同平台只需要安装对应平台的Java虚拟机即可运行(在JRE中包含),所有的Java程序都采用统一的标准,在任何平台编译出来的字节码文件(.class)也是同样的,最后实际上是将编译后的字节码交给JVM处理执行。 - -![点击查看图片来源](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg2018.cnblogs.com%2Fblog%2F314515%2F201912%2F314515-20191231163244928-184981058.png&refer=http%3A%2F%2Fimg2018.cnblogs.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1645942605&t=53b5ab5873cf233ff45f9fefb8aa87e8) - -也正是得益于这种统一规范,除了Java以外,还有多种JVM语言,比如Kotlin、Groovy等,它们的语法虽然和Java不一样,但是最终编译得到的字节码文件,和Java是同样的规范,同样可以交给JVM处理。 - -![点击查看图片来源](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg2020.cnblogs.com%2Fblog%2F2004486%2F202008%2F2004486-20200825201006756-1741469951.png&refer=http%3A%2F%2Fimg2020.cnblogs.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1645942643&t=4624e818442fd4bc90b26df9a9f7e5d1) - -所以,JVM是我们需要去关注的一个部分,通过了解Java的底层运作机制,我们的技术会得到质的提升。 - -## 技术概述 - -首先我们要了解虚拟机的具体定义,我们所接触过的虚拟机有安装操作系统的虚拟机,也有我们的Java虚拟机,而它们所面向的对象不同,Java虚拟机只是面向单一应用程序的虚拟机,但是它和我们接触的系统级虚拟机一样,我们也可以为其分配实际的硬件资源,比如最大内存大小等。 - -并且Java虚拟机并没有采用传统的PC架构,比如现在的HotSpot虚拟机,实际上采用的是`基于栈的指令集架构`,而我们的传统程序设计一般都是`基于寄存器的指令集架构`,这里我们需要回顾一下`计算机组成原理`中的CPU结构: - -![点击查看图片来源](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fupload-images.jianshu.io%2Fupload_images%2F9251733-5b4556af04fa3e5e.png&refer=http%3A%2F%2Fupload-images.jianshu.io&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1645971181&t=c9aaa14cb580afd4bc5dca3319c5344b) - -其中,**AX,BX,CX,DX 称作为数据寄存器:** - -* AX (Accumulator):累加寄存器,也称之为累加器; -* BX (Base):基地址寄存器; -* CX (Count):计数器寄存器; -* DX (Data):数据寄存器; - -这些寄存器可以用来传送数据和暂存数据,并且它们还可以细分为一个8位的高位寄存器和一个8位的低位寄存器,除了这些通用功能,它们各自也有自己的一些专属职责,比如AX就是一个专用于累加的寄存器,用的也比较多。 - -**SP 和 BP 又称作为指针寄存器:** - -* SP (Stack Pointer):堆栈指针寄存器,与SS配合使用,用于访问栈顶; -* BP (Base Pointer):基指针寄存器,可用作SS的一个相对基址位置,用它可直接存取堆栈中的数据; - -**SI 和 DI 又称作为变址寄存器:** - -* SI (Source Index):源变址寄存器; -* DI (Destination Index):目的变址寄存器; - -主要用于存放存储单元在段内的偏移量,用它们可实现多种存储器操作数的寻址方式,为以不同的地址形式访问存储单元提供方便。 - -**控制寄存器:** - -* IP (Instruction Pointer):指令指针寄存器; -* FLAG:标志寄存器; - -**段寄存器:** - -* CS (Code Segment):代码段寄存器; -* DS (Data Segment):数据段寄存器; -* SS (Stack Segment):堆栈段寄存器; -* ES (Extra Segment):附加段寄存器; - -这里我们分别比较一下在x86架构下C语言和arm架构下编译之后的汇编指令不同之处: - -```c -int main() { //实现一个最简的a+b功能,并存入变量c - int a = 10; - int b = 20; - int c = a + b; - return c; -} -``` - -```sh -gcc -S main.c -``` - -```assembly - .file "main.c" - .text - .globl main - .type main, @function -main: -.LFB0: - .cfi_startproc ;rbp寄存器是64位CPU下的基址寄存器,和8086CPU的16位bp一样 - pushq %rbp ;该函数中需要用到rbp寄存器,所以需要先把他原来的值压栈保护起来 - .cfi_def_cfa_offset 16 - .cfi_offset 6, -16 - movq %rsp, %rbp ;rsp是64位下的栈指针寄存器,这里是将rsp的值丢给rbp,因为局部变量是存放在栈中的,之后会使用rbp来访问局部变量 - .cfi_def_cfa_register 6 - movl $10, -12(%rbp) ;将10存入rbp所指向位置-12的位置 -> int a = 10; - movl $20, -8(%rbp) ;将20存入rbp所指向位置-8的位置 -> int b = 20; - movl -12(%rbp), %edx ;将变量a的值交给DX寄存器(32位下叫edx,因为是int,这里只使用了32位) - movl -8(%rbp), %eax ;同上,变量b的值丢给AX寄存器 - addl %edx, %eax ;将DX和AX寄存器中的值相加,并将结果存在AX中 -> tmp = a + b - movl %eax, -4(%rbp) ;将20存入rbp所指向位置-4的位置 -> int c = tmp;与上面合在一起就是int c = a + b; - movl -4(%rbp), %eax ;根据约定,将函数返回值放在AX -> return c; - popq %rbp ;函数执行完毕,出栈 - .cfi_def_cfa 7, 8 - ret ;函数返回 - .cfi_endproc -.LFE0: - .size main, .-main - .ident "GCC: (Ubuntu 7.5.0-6ubuntu2) 7.5.0" - .section .note.GNU-stack,"",@progbits -``` - -在arm架构下(Apple M1 Pro芯片)编译的结果为: - -```assembly - .section __TEXT,__text,regular,pure_instructions - .build_version macos, 12, 0 sdk_version 12, 1 - .globl _main ; -- Begin function main - .p2align 2 -_main: ; @main - .cfi_startproc -; %bb.0: - sub sp, sp, #16 ; =16 - .cfi_def_cfa_offset 16 - str wzr, [sp, #12] - mov w8, #10 - str w8, [sp, #8] - mov w8, #20 - str w8, [sp, #4] - ldr w8, [sp, #8] - ldr w9, [sp, #4] - add w8, w8, w9 - str w8, [sp] - ldr w0, [sp] - add sp, sp, #16 ; =16 - ret - .cfi_endproc - ; -- End function -.subsections_via_symbols -``` - -我们发现,在不同的CPU架构下,实际上得到的汇编代码也不一样,并且在arm架构下并没有和x86架构一样的寄存器结构,因此只能使用不同的汇编指令操作来实现。所以这也是为什么C语言不支持跨平台的原因,我们只能将同样的代码在不同的平台上编译之后才能在对应的平台上运行我们的程序。而Java利用了JVM,它提供了很好的平台无关性(当然,JVM本身是不跨平台的),我们的Java程序编译之后,并不是可以由平台直接运行的程序,而是由JVM运行,同时,我们前面说了,JVM(如HotSpot虚拟机),实际上采用的是`基于栈的指令集架构`,它并没有依赖于寄存器,而是更多的利用操作栈来完成,这样不仅设计和实现起来更简单,并且也能够更加方便地实现跨平台,不太依赖于硬件的支持。 - -这里我们对一个类进行反编译查看: - -```java -public class Main { - public int test(){ //和上面的例子一样 - int a = 10; - int b = 20; - int c = a + b; - return c; - } -} -``` - -```sh -javap -v target/classes/com/test/Main.class #使用javap命令对class文件进行反编译 -``` - -得到如下结果: - -``` -... -public int test(); - descriptor: ()I - flags: ACC_PUBLIC - Code: - stack=2, locals=4, args_size=1 - 0: bipush 10 - 2: istore_1 - 3: bipush 20 - 5: istore_2 - 6: iload_1 - 7: iload_2 - 8: iadd - 9: istore_3 - 10: iload_3 - 11: ireturn - LineNumberTable: - line 5: 0 - line 6: 3 - line 7: 6 - line 8: 10 - LocalVariableTable: - Start Length Slot Name Signature - 0 12 0 this Lcom/test/Main; - 3 9 1 a I - 6 6 2 b I - 10 2 3 c I -``` - -我们可以看到,java文件编译之后,也会生成类似于C语言那样的汇编指令,但是这些命令都是交给JVM去执行的命令(实际上虚拟机提供了一个类似于物理机的运行环境,也有程序计数器之类的东西),最下方存放的是本地变量(局部变量)表,表示此方法中出现的本地变量,实际上this也在其中,所以我们才能在非静态方法中使用`this`关键字,在最上方标记了方法的返回值类型、访问权限等。首先介绍一下例子中出现的命令代表什么意思: - -* bipush 将单字节的常量值推到栈顶 -* istore_1 将栈顶的int类型数值存入到第二个本地变量 -* istore_2 将栈顶的int类型数值存入到第三个本地变量 -* istore_3 将栈顶的int类型数值存入到第四个本地变量 -* iload_1 将第二个本地变量推向栈顶 -* iload_2 将第三个本地变量推向栈顶 -* iload_3 将第四个本地变量推向栈顶 -* iadd 将栈顶的两个int类型变量相加,并将结果压入栈顶 -* ireturn 方法的返回操作 - -有关详细的指令介绍列表可以参考《深入理解Java虚拟机 第三版》附录C。 - -JVM运行字节码时,所有的操作基本都是围绕两种数据结构,一种是堆栈(本质是栈结构),还有一种是队列,如果JVM执行某条指令时,该指令需要对数据进行操作,那么被操作的数据在指令执行前,必须要压到堆栈上,JVM会自动将栈顶数据作为操作数。如果堆栈上的数据需要暂时保存起来时,那么它就会被存储到局部变量队列上。 - -我们从第一条指令来依次向下解读,显示方法相关属性: - - descriptor: ()I //参数以及返回值类型,()I就表示没有形式参数,返回值为基本类型int - flags: ACC_PUBLIC //public访问权限 - Code: - stack=2, locals=4, args_size=1 //stack表示要用到的最大栈深度,本地变量数,堆栈上最大对象数量(这里指的是this) - -有关descriptor的详细属性介绍,我们会放在之后的类结构中进行讲解。 - -接着我们来看指令: - -``` -0: bipush 10 //0是程序偏移地址,然后是指令,最后是操作数 -2: istore_1 -``` - -这一步操作实际上就是使用`bipush`将10推向栈顶,接着使用`istore_1`将当前栈顶数据存放到第二个局部变量中,也就是a,所以这一步执行的是`int a = 10`操作。 - -``` -3: bipush 20 -5: istore_2 -``` - -同上,这里执行的是`int b = 20`操作。 - -``` -6: iload_1 -7: iload_2 -8: iadd -``` - -这里是将第二和第三个局部变量放到栈中,也就是取a和b的值到栈中,最后`iadd`操作将栈中的两个值相加,结果依然放在栈顶。 - -``` -9: istore_3 -10: iload_3 -11: ireturn -``` - -将栈顶数据存放到第四个局部变量中,也就是c,执行的是`int c = 30`,最后取出c的值放入栈顶,使用`ireturn`返回栈顶值,也就是方法的返回值。 - -至此,方法执行完毕。 - -实际上我们发现,JVM执行的命令基本都是入栈出栈等,而且大部分指令都是没有操作数的,传统的汇编指令有一操作数、二操作数甚至三操作数的指令,Java相比C编译出来的汇编指令,执行起来会更加复杂,实现某个功能的指令条数也会更多,所以Java的执行效率实际上是不如C/C++的,虽然能够很方便地实现跨平台,但是性能上大打折扣,所以在性能要求比较苛刻的Android上,采用的是定制版的JVM,并且是基于寄存器的指令集架构。此外,在某些情况下,我们还可以使用JNI机制来通过Java调用C/C++编写的程序以提升性能(也就是本地方法,使用到native关键字) - -*** - -## 现在与未来 - -随着时代的变迁,JVM的实现多种多样,而我们还要从最初的虚拟机说起。 - -### 虚拟机的发展历程 - -在1996,Java1.0面世时,第一款商用虚拟机Sun Classic VM开始了它的使命,这款虚拟机提供了一个Java解释器,也就是将我们的class文件进行读取,最后像上面一样得到一条一条的命令,JVM再将指令依次执行。虽然这样的运行方式非常的简单易懂,但是它的效率实际上是很低的,就像你耳机里一边在放六级听力,你必须同时记在脑海里面然后等着问问题,再去选择问题的答案一样,更重要的是同样的代码每次都需要重新翻译再执行。 - -这个时候我们就需要更加高效的方式来运行Java程序,随着后面的发展,现在大多数的主流的JVM都包含即时**编译器**。JVM会根据当前代码的进行判断,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler) - -![img](https://img2018.cnblogs.com/blog/955092/201911/955092-20191118100603404-2016014845.jpg) - -在JDK1.4时,Sun Classic VM完全退出了历史舞台,取而代之的是至今都在使用的HotSpot VM,它是目前使用最广泛的虚拟机,拥有上面所说的热点代码探测技术、准确式内存管理(虚拟机可以知道内存中某个位置的数据具体是什么类型)等技术,而我们之后的章节都是基于HotSpot虚拟机进行讲解。 - -### 虚拟机发展的未来 - -2018年4月,Oracle Labs公开了最新的GraalVM,它是一种全新的虚拟机,它能够实现所有的语言统一运行在虚拟机中。 - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fatoracle.cn%2FUploads%2Fgraalvm%2Fgraalvm.png&refer=http%3A%2F%2Fatoracle.cn&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1646031057&t=1cfa58c28f680c3f23eb85bde2d31e1f) - -Graal VM被官方称为“Universal VM”和“Polyglot VM”,这是一个在HotSpot虚拟机基础上增强而成的跨语言全栈虚拟机,可以作为“任何语言”的运行平台使用,这里“任何语言”包括了Java、Scala、Groovy、Kotlin等基于Java虚拟机之上的语言,还包括了C、C++、Rust等基于LLVM的语言,同时支持其他像JavaScript、Ruby、Python和R语言等等。Graal VM可以无额外开销地混合使用这些编程语言,支持不同语言中混用对方的接口和对象,也能够支持这些语言使用已经编写好的本地库文件。 - -Graal VM的基本工作原理是将这些语言的源代码(例如JavaScript)或源代码编译后的中间格式(例如LLVM字节码)通过解释器转换为能被Graal VM接受的中间表示(Intermediate Representation,IR),譬如设计一个解释器专门对LLVM输出的字节码进行转换来支持C和C++语言,这个过程称为“程序特化”(Specialized,也常称为Partial Evaluation)。Graal VM提供了Truffle工具集来快速构建面向一种新语言的解释器,并用它构建了一个称为Sulong的高性能LLVM字节码解释器。 - -目前最新的SpringBoot已经提供了本地运行方案:https://docs.spring.io/spring-native/docs/current/reference/htmlsingle/ - -> Spring Native支持使用[GraalVM](https://www.graalvm.org/)[原生镜像](https://www.graalvm.org/reference-manual/native-image/)编译器将Spring应用程序编译为本机可执行文件。 -> -> 与Java虚拟机相比,原生映像可以为许多类型的工作负载实现更简单、更加持续的托管。包括微服务、非常适合容器的功能工作负载和[Kubernetes](https://kubernetes.io/) -> -> 使用本机映像提供了关键优势,如即时启动、即时峰值性能和减少内存消耗。 -> -> GraalVM原生项目预计随着时间的推移会改进一些缺点和权衡。构建本机映像是一个比常规应用程序慢的繁重过程。热身后的本机映像运行时优化较少。最后,它不如JVM成熟,行为各不相同。 -> -> 常规JVM和此原生映像平台的主要区别是: -> -> - 从主入口点对应用程序进行静态分析,在构建时进行。 -> - 未使用的部件将在构建时删除。 -> - 反射、资源和动态代理需要配置。 -> - Classpath在构建时是固定的。 -> - 没有类惰性加载:可执行文件中运送的所有内容将在启动时加载到内存中。 -> - 一些代码将在构建时运行。 -> - Java应用程序的某些方面有一些不受完全支持[的限制](https://www.graalvm.org/reference-manual/native-image/Limitations/)。 -> -> 该项目的目标是孵化对Spring Native的支持,Spring Native是Spring JVM的替代品,并提供旨在打包在轻量级容器中的原生部署选项。在实践中,目标是在这个新平台上支持您的Spring应用程序,几乎未经修改。 - -优点: - -1. 立即启动,一般启动时间小于100ms -2. 更低的内存消耗 -3. 独立部署,不再需要JVM -4. 同样的峰值性能要比JVM消耗的内存小 - -缺点: - -1. 构建时间长 -2. 只支持新的Springboot版本(2.4.4+) - -*** - -## 手动编译JDK8 - -学习JVM最关键的是研究底层C/C++源码,我们首先需要搭建一个测试环境,方便我们之后对底层源码进行调试。但是编译这一步的坑特别多,请务必保证跟教程中的环境一致,尤其是编译环境,版本不能太高,因为JDK8属于比较早期的版本了,否则会遇到各种各样奇奇怪怪的问题。 - -### 环境配置 - -* 操作系统:Ubuntu 20.04 Server -* 硬件配置:i7-4790 4C8T/ 16G内存 / 128G硬盘 (不能用树莓派或是arm芯片Mac的虚拟机,配置越高越好,不然卡爆) -* 调试工具:Jetbrains Gateway(服务器运行CLion Backend程序,界面在Mac上显示) -* OpenJDK源码:https://codeload.github.com/openjdk/jdk/zip/refs/tags/jdk8-b120 -* 编译环境: - * gcc-4.8 - * g++-4.8 - * make-3.81 - * openjdk-8 - -### 开始折腾 - -首选需要在我们的测试服务器上安装Ubuntu 20.04 Server系统,并通过ssh登录到服务器: - -```sh -Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-96-generic x86_64) - - * Documentation: https://help.ubuntu.com - * Management: https://landscape.canonical.com - * Support: https://ubuntu.com/advantage - - System information as of Sat 29 Jan 2022 10:33:03 AM UTC - - System load: 0.08 Processes: 156 - Usage of /: 5.5% of 108.05GB Users logged in: 0 - Memory usage: 5% IPv4 address for enp2s0: 192.168.10.66 - Swap usage: 0% IPv4 address for enp2s0: 192.168.10.75 - Temperature: 32.0 C - - -37 updates can be applied immediately. -To see these additional updates run: apt list --upgradable - - -Last login: Sat Jan 29 10:27:06 2022 -nagocoler@ubuntu-server:~$ -``` - -先安装一些基本的依赖: - -```sh -sudo apt install build-essential libxrender-dev xorg-dev libasound2-dev libcups2-dev gawk zip libxtst-dev libxi-dev libxt-dev gobjc -``` - -接着我们先将JDK的编译环境配置好,首先是安装gcc和g++的4.8版本,但是最新的源没有这个版本了,我们先导入旧版软件源: - -```sh -sudo vim /etc/apt/sources.list -``` - -在最下方添加旧版源地址并保存: - -``` -deb http://archive.ubuntu.com/ubuntu xenial main -deb http://archive.ubuntu.com/ubuntu xenial universe -``` - -接着更新一下apt源信息,并安装gcc和g++: - -```sh -sudo apt update -sudo apt install gcc-4.8 g++-4.8 -``` - -接着配置: - -```sh -sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-4.8 100 -sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-4.8 100 -``` - -最后查看版本是否为4.8版本: - -```sh -nagocoler@ubuntu-server:~$ gcc --version -gcc (Ubuntu 4.8.5-4ubuntu2) 4.8.5 -Copyright (C) 2015 Free Software Foundation, Inc. -This is free software; see the source for copying conditions. There is NO -warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - -nagocoler@ubuntu-server:~$ g++ --version -g++ (Ubuntu 4.8.5-4ubuntu2) 4.8.5 -Copyright (C) 2015 Free Software Foundation, Inc. -This is free software; see the source for copying conditions. There is NO -warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. -``` - -接着安装make 3.81版本,需要从官方下载: - -```sh -wget https://ftp.gnu.org/gnu/make/make-3.81.tar.gz -``` - -下载好之后进行解压,并进入目录: - -```sh -tar -zxvf make-3.81.tar.gz -cd make-3.81/ -``` - -接着我们修改一下代码,打开`glob/glob.c`文件: - -```c -... -#ifdef HAVE_CONFIG_H -# include -#endif - -#define __alloca alloca <- 添加这一句 -/* Enable GNU extensions -... -``` - -接着进行配置并完成编译和安装: - -```sh -bash configure -sudo make install -``` - -安装完成后,将make已经变成3.81版本了: - -```sh -nagocoler@ubuntu-server:~/make-3.81$ make -verison -GNU Make 3.81 -Copyright (C) 2006 Free Software Foundation, Inc. -This is free software; see the source for copying conditions. -There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A -PARTICULAR PURPOSE. -``` - -由于JDK中某些代码是Java编写的,所以我们还需要安装一个启动JDK,启动JDK可以是当前版本或低一版本,比如我们要编译JDK8的源码,那么就可以使用JDK7、JDK8作为启动JDK,对源码中的一些java文件进行编译。这里我们选择安装OpenJDK8作为启动JDK: - -```sh -sudo apt install openjdk-8-jdk -``` - -这样,我们的系统环境就准备完成了,接着我们需要下载OpenJDK8的源码(已经放在网盘了)解压: - -```sh -unzip jdk-jdk8-b120.zip -``` - -接着我们需要安装JetBrains Gateway在我们的服务器上导入项目,这里我们使用CLion后端,等待下载远程后端,这样我们的Linux服务器上虽然没有图形化界面,但是依然可以使用IDEA、CLion等工具,只是服务器上只有后端程序,而界面由我们电脑上的前端程序提供(目前此功能还在Beta阶段,暂不支持arm架构的Linux服务器)整个过程根据服务器配置决定可能需要5-20分钟。 - -完成之后,我们操作起来就很方便了,界面和IDEA其实差不多,我们打开终端,开始进行配置: - -```sh -bash configure --with-debug-level=slowdebug --enable-debug-symbols ZIP_DEBUGINFO_FIELS=0 -``` - -配置完成后,再次确认是否和教程中的配置信息一致: - -``` -Configuration summary: -* Debug level: slowdebug -* JDK variant: normal -* JVM variants: server -* OpenJDK target: OS: linux, CPU architecture: x86, address length: 64 - -Tools summary: -* Boot JDK: openjdk version "1.8.0_312" OpenJDK Runtime Environment (build 1.8.0_312-8u312-b07-0ubuntu1~20.04-b07) OpenJDK 64-Bit Server VM (build 25.312-b07, mixed mode) (at /usr/lib/jvm/java-8-openjdk-amd64) -* C Compiler: gcc-4.8 (Ubuntu 4.8.5-4ubuntu2) version 4.8.5 (at /usr/bin/gcc-4.8) -* C++ Compiler: g++-4.8 (Ubuntu 4.8.5-4ubuntu2) version 4.8.5 (at /usr/bin/g++-4.8) - -Build performance summary: -* Cores to use: 3 -* Memory limit: 3824 MB -* ccache status: not installed (consider installing) - -WARNING: The result of this configuration has overridden an older -configuration. You *should* run 'make clean' to make sure you get a -proper build. Failure to do so might result in strange build problems. -``` - -接着我们需要修改几个文件,不然一会会编译失败,首先是`hotspot/make/linux/Makefile`文件: - -``` -原有的 SUPPORTED_OS_VERSION = 2.4% 2.5% 2.6% 3% -修改为 SUPPORTED_OS_VERSION = 2.4% 2.5% 2.6% 3% 4% 5% -``` - -接着是`hotspot/make/linux/makefiles/gcc.make`文件: - -``` -原有的 WARNINGS_ARE_ERRORS = -Werror -修改为 #WARNINGS_ARE_ERRORS = -Werror -``` - -接着是`nashorn/make/BuildNashorn.gmk`文件: - -``` - $(CP) -R -p $(NASHORN_OUTPUTDIR)/nashorn_classes/* $(@D)/ - $(FIXPATH) $(JAVA) \ -原有的 -cp "$(NASHORN_OUTPUTDIR)/nasgen_classes$(PATH_SEP)$(NASHORN_OUTPUTDIR)/nashorn_classes" \ -修改为 -Xbootclasspath/p:"$(NASHORN_OUTPUTDIR)/nasgen_classes$(PATH_SEP)$(NASHORN_OUTPUTDIR)/nashorn_classes" \ - jdk.nashorn.internal.tools.nasgen.Main $(@D) jdk.nashorn.internal.objects $(@D) -``` - -OK,修改完成,接着我们就可以开始编译了: - -``` -make all -``` - -整个编译过程大概需要持续10-20分钟,请耐心等待。构建完成后提示: - -``` ------ Build times ------- -Start 2022-01-29 11:36:35 -End 2022-01-29 11:48:20 -00:00:30 corba -00:00:25 demos -00:02:39 docs -00:03:05 hotspot -00:00:27 images -00:00:17 jaxp -00:00:31 jaxws -00:03:02 jdk -00:00:38 langtools -00:00:11 nashorn -00:11:45 TOTAL -------------------------- -Finished building OpenJDK for target 'all' -``` - -只要按照我们的教程一步步走,别漏了,应该是直接可以完成的,当然难免可能有的同学出现了奇奇怪怪的问题,加油,慢慢折腾,总会成功的~ - -接着我们就可以创建一个测试配置了,首先打开设置页面,找到`自定义构建目标`: - -![image-20220129195318339](https://tva1.sinaimg.cn/large/008i3skNly1gyux37s99nj31b80u076s.jpg) - -点击`应用`即可,接着打开运行配置,添加一个新的自定义配置: - -![image-20220129195459914](https://tva1.sinaimg.cn/large/008i3skNly1gyux3axcknj31ai0u0wgy.jpg) - -选择我们编译完成的java程序,然后测试-version查看版本信息,去掉下方的构建。 - -接着直接运行即可: - -``` -/home/nagocoler/jdk-jdk8-b120/build/linux-x86_64-normal-server-slowdebug/jdk/bin/java -version -openjdk version "1.8.0-internal-debug" -OpenJDK Runtime Environment (build 1.8.0-internal-debug-nagocoler_2022_01_29_11_36-b00) -OpenJDK 64-Bit Server VM (build 25.0-b62-debug, mixed mode) - -Process finished with exit code 0 -``` - -我们可以将工作目录修改到其他地方,接着我们创建一个Java文件并完成编译,然后测试能否使用我们编译的JDK运行: - -![image-20220129195801789](https://tva1.sinaimg.cn/large/008i3skNly1gyux3dp9bsj31ai0u0wh5.jpg) - -在此目录下编写一个Java程序,然后编译: - -```java -public class Main{ - public static void main(String[] args){ - System.out.println("Hello World!"); - } -} -``` - -```sh -nagocoler@ubuntu-server:~$ cd JavaHelloWorld/ -nagocoler@ubuntu-server:~/JavaHelloWorld$ vim Main.java -nagocoler@ubuntu-server:~/JavaHelloWorld$ javac Main.java -nagocoler@ubuntu-server:~/JavaHelloWorld$ ls -Main.class Main.java -``` - -点击运行,成功得到结果: - -``` -/home/nagocoler/jdk-jdk8-b120/build/linux-x86_64-normal-server-slowdebug/jdk/bin/java Main -Hello World! - -Process finished with exit code 0 -``` - -我们还可以在CLion前端页面中进行断点调试,比如我们测试一个入口点JavaMain,在`jdk/src/share/bin/java.c`中的JavaMain方法: - -![image-20220129200244279](https://tva1.sinaimg.cn/large/008i3skNly1gyux47wgp9j31z00sc0xc.jpg) - -点击右上角调试按钮,可以成功进行调试: - -![image-20220129200314691](https://tva1.sinaimg.cn/large/008i3skNly1gyux4lmirkj31mk0u0gq2.jpg) - -至此,在Ubuntu系统上手动编译OpenJDK8完成。 - -*** - -## JVM启动流程探究 - -前面我们完成了JDK8的编译,也了解了如何进行断点调试,现在我们就可以来研究一下JVM的启动流程了,首先我们要明确,虚拟机的启动入口位于`jdk/src/share/bin/java.c`的`JLI_Launch`函数,整个流程分为如下几个步骤: - -1. 配置JVM装载环境 -2. 解析虚拟机参数 -3. 设置线程栈大小 -4. 执行JavaMain方法 - -首先我们来看看`JLI_Launch`函数是如何定义的: - -```c -int -JLI_Launch(int argc, char ** argv, /* main argc, argc */ - int jargc, const char** jargv, /* java args */ - int appclassc, const char** appclassv, /* app classpath */ - const char* fullversion, /* full version defined */ - const char* dotversion, /* dot version defined */ - const char* pname, /* program name */ - const char* lname, /* launcher name */ - jboolean javaargs, /* JAVA_ARGS */ - jboolean cpwildcard, /* classpath wildcard */ - jboolean javaw, /* windows-only javaw */ - jint ergo_class /* ergnomics policy */ -); -``` - -可以看到在入口点的参数有很多个,其中包括当前的完整版本名称、简短版本名称、运行参数、程序名称、启动器名称等。 - -首先会进行一些初始化操作以及Debug信息打印配置等: - -```c -InitLauncher(javaw); -DumpState(); -if (JLI_IsTraceLauncher()) { - int i; - printf("Command line args:\n"); - for (i = 0; i < argc ; i++) { - printf("argv[%d] = %s\n", i, argv[i]); - } - AddOption("-Dsun.java.launcher.diag=true", NULL); -} -``` - -接着就是选择一个合适的JRE版本: - -```c -/* - * Make sure the specified version of the JRE is running. - * - * There are three things to note about the SelectVersion() routine: - * 1) If the version running isn't correct, this routine doesn't - * return (either the correct version has been exec'd or an error - * was issued). - * 2) Argc and Argv in this scope are *not* altered by this routine. - * It is the responsibility of subsequent code to ignore the - * arguments handled by this routine. - * 3) As a side-effect, the variable "main_class" is guaranteed to - * be set (if it should ever be set). This isn't exactly the - * poster child for structured programming, but it is a small - * price to pay for not processing a jar file operand twice. - * (Note: This side effect has been disabled. See comment on - * bugid 5030265 below.) - */ -SelectVersion(argc, argv, &main_class); -``` - -接着是创建JVM执行环境,例如需要确定数据模型,是32位还是64位,以及jvm本身的一些配置在jvm.cfg文件中读取和解析: - -```c -CreateExecutionEnvironment(&argc, &argv, - jrepath, sizeof(jrepath), - jvmpath, sizeof(jvmpath), - jvmcfg, sizeof(jvmcfg)); -``` - -此函数只在头文件中定义,具体的实现是根据不同平台而定的。接着会动态加载jvm.so这个共享库,并把jvm.so中的相关函数导出并且初始化,而启动JVM的函数也在其中: - -```c -if (!LoadJavaVM(jvmpath, &ifn)) { - return(6); -} -``` - -比如mac平台下的实现: - -```c -jboolean -LoadJavaVM(const char *jvmpath, InvocationFunctions *ifn) -{ - Dl_info dlinfo; - void *libjvm; - - JLI_TraceLauncher("JVM path is %s\n", jvmpath); - - libjvm = dlopen(jvmpath, RTLD_NOW + RTLD_GLOBAL); - if (libjvm == NULL) { - JLI_ReportErrorMessage(DLL_ERROR1, __LINE__); - JLI_ReportErrorMessage(DLL_ERROR2, jvmpath, dlerror()); - return JNI_FALSE; - } - - ifn->CreateJavaVM = (CreateJavaVM_t) - dlsym(libjvm, "JNI_CreateJavaVM"); - if (ifn->CreateJavaVM == NULL) { - JLI_ReportErrorMessage(DLL_ERROR2, jvmpath, dlerror()); - return JNI_FALSE; - } - - ifn->GetDefaultJavaVMInitArgs = (GetDefaultJavaVMInitArgs_t) - dlsym(libjvm, "JNI_GetDefaultJavaVMInitArgs"); - if (ifn->GetDefaultJavaVMInitArgs == NULL) { - JLI_ReportErrorMessage(DLL_ERROR2, jvmpath, dlerror()); - return JNI_FALSE; - } - - ifn->GetCreatedJavaVMs = (GetCreatedJavaVMs_t) - dlsym(libjvm, "JNI_GetCreatedJavaVMs"); - if (ifn->GetCreatedJavaVMs == NULL) { - JLI_ReportErrorMessage(DLL_ERROR2, jvmpath, dlerror()); - return JNI_FALSE; - } - - return JNI_TRUE; -} -``` - -最后就是对JVM进行初始化了: - -```c -return JVMInit(&ifn, threadStackSize, argc, argv, mode, what, ret); -``` - -这也是由平台决定的,比如Mac下的实现为: - -```c -int -JVMInit(InvocationFunctions* ifn, jlong threadStackSize, - int argc, char **argv, - int mode, char *what, int ret) { - if (sameThread) { - //无需关心.... - } else { - //正常情况下走这个 - return ContinueInNewThread(ifn, threadStackSize, argc, argv, mode, what, ret); - } -} -``` - -可以看到最后进入了一个`ContinueInNewThread`函数(在刚刚的`java.c`中实现),这个函数会创建一个新的线程来执行: - -```c -int -ContinueInNewThread(InvocationFunctions* ifn, jlong threadStackSize, - int argc, char **argv, - int mode, char *what, int ret) -{ - - ... - - rslt = ContinueInNewThread0(JavaMain, threadStackSize, (void*)&args); - /* If the caller has deemed there is an error we - * simply return that, otherwise we return the value of - * the callee - */ - return (ret != 0) ? ret : rslt; - } -} -``` - -接着进入了一个名为`ContinueInNewThread0`的函数,可以看到它将`JavaMain`函数传入作为参数,而此函数定义的第一个参数类型是一个函数指针: - -```c -int -ContinueInNewThread0(int (JNICALL *continuation)(void *), jlong stack_size, void * args) { - int rslt; - pthread_t tid; - pthread_attr_t attr; - pthread_attr_init(&attr); - pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE); - - if (stack_size > 0) { - pthread_attr_setstacksize(&attr, stack_size); - } - - if (pthread_create(&tid, &attr, (void *(*)(void*))continuation, (void*)args) == 0) { - void * tmp; - pthread_join(tid, &tmp); - rslt = (int)tmp; - } else { - /* - * Continue execution in current thread if for some reason (e.g. out of - * memory/LWP) a new thread can't be created. This will likely fail - * later in continuation as JNI_CreateJavaVM needs to create quite a - * few new threads, anyway, just give it a try.. - */ - rslt = continuation(args); - } - - pthread_attr_destroy(&attr); - return rslt; -} -``` - -最后实际上是在新的线程中执行`JavaMain`函数,最后我们再来看看此函数里面做了什么事情: - -```c -/* Initialize the virtual machine */ -start = CounterGet(); -if (!InitializeJVM(&vm, &env, &ifn)) { - JLI_ReportErrorMessage(JVM_ERROR1); - exit(1); -} -``` - -第一步初始化虚拟机,如果报错直接退出。接着就是加载主类(至于具体如何加载一个类,我们会放在后面进行讲解),因为主类是我们Java程序的入口点: - -```c -/* - * Get the application's main class. - * - * See bugid 5030265. The Main-Class name has already been parsed - * from the manifest, but not parsed properly for UTF-8 support. - * Hence the code here ignores the value previously extracted and - * uses the pre-existing code to reextract the value. This is - * possibly an end of release cycle expedient. However, it has - * also been discovered that passing some character sets through - * the environment has "strange" behavior on some variants of - * Windows. Hence, maybe the manifest parsing code local to the - * launcher should never be enhanced. - * - * Hence, future work should either: - * 1) Correct the local parsing code and verify that the - * Main-Class attribute gets properly passed through - * all environments, - * 2) Remove the vestages of maintaining main_class through - * the environment (and remove these comments). - * - * This method also correctly handles launching existing JavaFX - * applications that may or may not have a Main-Class manifest entry. - */ -mainClass = LoadMainClass(env, mode, what); -``` - -某些没有主方法的Java程序比如JavaFX应用,会获取ApplicationMainClass: - -```c -/* - * In some cases when launching an application that needs a helper, e.g., a - * JavaFX application with no main method, the mainClass will not be the - * applications own main class but rather a helper class. To keep things - * consistent in the UI we need to track and report the application main class. - */ -appClass = GetApplicationClass(env); -``` - -初始化完成: - -```c -/* - * PostJVMInit uses the class name as the application name for GUI purposes, - * for example, on OSX this sets the application name in the menu bar for - * both SWT and JavaFX. So we'll pass the actual application class here - * instead of mainClass as that may be a launcher or helper class instead - * of the application class. - */ -PostJVMInit(env, appClass, vm); -``` - -接着就是获取主类中的主方法: - -```java -/* - * The LoadMainClass not only loads the main class, it will also ensure - * that the main method's signature is correct, therefore further checking - * is not required. The main method is invoked here so that extraneous java - * stacks are not in the application stack trace. - */ -mainID = (*env)->GetStaticMethodID(env, mainClass, "main", - "([Ljava/lang/String;)V"); -``` - -没错,在字节码中`void main(String[] args)`表示为`([Ljava/lang/String;)V`我们之后会详细介绍。接着就是调用主方法了: - -```c -/* Invoke main method. */ -(*env)->CallStaticVoidMethod(env, mainClass, mainID, mainArgs); -``` - -调用后,我们的Java程序就开飞速运行起来,直到走到主方法的最后一行返回: - -```c -/* - * The launcher's exit code (in the absence of calls to - * System.exit) will be non-zero if main threw an exception. - */ -ret = (*env)->ExceptionOccurred(env) == NULL ? 0 : 1; -LEAVE(); -``` - -至此,一个Java程序的运行流程结束,在最后LEAVE函数中会销毁JVM。我们可以进行断点调试来查看是否和我们推出的结论一致: - -![image-20220129211342240](https://tva1.sinaimg.cn/large/008i3skNly1gyux4uqcxpj31sr0u0td4.jpg) - -还是以我们之前编写的测试类进行,首先来到调用之前,我们看到主方法执行之前,控制台没有输出任何内容,接着我们执行此函数,再来观察控制台的变化: - -![image-20220129211450939](https://tva1.sinaimg.cn/large/008i3skNly1gyux4w5322j31zt0u0afp.jpg) - -可以看到,主方法执行完成之后,控制台也成功输出了Hello World! - -继续下一步,整个Java程序执行完成,得到退出状态码`0`: - -![image-20220129211540210](https://tva1.sinaimg.cn/large/008i3skNly1gyux4ydghaj31bk0eimy7.jpg) - -成功验证,最后总结一下整个执行过程: - -![image-20220129213143973](https://tva1.sinaimg.cn/large/008i3skNly1gyux50ahdrj31d30u0tdu.jpg) - -*** - -## JNI调用本地方法 - -Java还有一个JNI机制,它的全称:Java Native Interface,即Java本地接口。它允许在Java虚拟机内运行的Java代码与其他编程语言(如C/C++和汇编语言)编写的程序和库进行交互(在Android开发中用得比较多)比如我们现在想要让C语言程序帮助我们的Java程序实现a+b的运算,首先我们需要创建一个本地方法: - -```java -public class Main { - public static void main(String[] args) { - System.out.println(sum(1, 2)); - } - - //本地方法使用native关键字标记,无需任何实现,交给C语言实现 - public static native int sum(int a, int b); -} -``` - -创建好后,接着点击构建按钮,会出现一个out文件夹,也就是生成的class文件在其中,接着我们直接生成对应的C头文件: - -```sh -javah -classpath out/production/SimpleHelloWorld -d ./jni com.test.Main -``` - -生成的头文件位于jni文件夹下: - -```c -/* DO NOT EDIT THIS FILE - it is machine generated */ -#include -/* Header for class com_test_Main */ - -#ifndef _Included_com_test_Main -#define _Included_com_test_Main -#ifdef __cplusplus -extern "C" { -#endif -/* - * Class: com_test_Main - * Method: sum - * Signature: (II)V - */ -JNIEXPORT void JNICALL Java_com_test_Main_sum - (JNIEnv *, jclass, jint, jint); - -#ifdef __cplusplus -} -#endif -#endif -``` - -接着我们在CLion中新建一个C++项目,并引入刚刚生成的头文件,并导入jni相关头文件(在JDK文件夹中)首先修改CMake文件: - -```cmake -cmake_minimum_required(VERSION 3.21) -project(JNITest) - -include_directories(/Library/Java/JavaVirtualMachines/zulu-8.jdk/Contents/Home/include) -include_directories(/Library/Java/JavaVirtualMachines/zulu-8.jdk/Contents/Home/include/darwin) -set(CMAKE_CXX_STANDARD 14) - -add_executable(JNITest com_test_Main.cpp com_test_Main.h) -``` - -接着就可以编写实现了,首先认识一下引用类型对照表: - -![img](https://tva1.sinaimg.cn/large/008i3skNly1gyux540wn7j30xc0h1q47.jpg) - -所以我们这里直接返回a+b即可: - -```cpp -#include "com_test_Main.h" - -JNIEXPORT jint JNICALL Java_com_test_Main_sum - (JNIEnv * env, jclass clazz, jint a, jint b){ - return a + b; -} -``` - -接着我们就可以将cpp编译为动态链接库,在MacOS下会生成`.dylib`文件,Windows下会生成`.dll`文件,我们这里就只以MacOS为例,命令有点长,因为还需要包含JDK目录下的头文件: - -```sh -gcc com_test_Main.cpp -I /Library/Java/JavaVirtualMachines/zulu-8.jdk/Contents/Home/include -I /Library/Java/JavaVirtualMachines/zulu-8.jdk/Contents/Home/include/darwin -fPIC -shared -o test.dylib -lstdc++ -``` - -编译完成后,得到`test.dylib`文件,这就是动态链接库了。 - -最后我们再将其放到桌面,然后在Java程序中加载: - -```java -public class Main { - static { - System.load("/Users/nagocoler/Desktop/test.dylib"); - } - - public static void main(String[] args) { - System.out.println(sum(1, 2)); - } - - public static native int sum(int a, int b); -} -``` - -运行,成功得到结果: - -![image-20220129222858105](https://tva1.sinaimg.cn/large/008i3skNly1gyux58pg32j31ag0smjv5.jpg) - -通过了解JVM的一些基础知识,我们心目中大致有了一个JVM的模型,在下一章,我们将继续深入学习JVM的内存管理机制和垃圾收集器机制,以及一些实用工具。 \ No newline at end of file diff --git a/青空笔记/JVM笔记/JVM笔记(三).md b/青空笔记/JVM笔记/JVM笔记(三).md deleted file mode 100644 index cd9f117..0000000 --- a/青空笔记/JVM笔记/JVM笔记(三).md +++ /dev/null @@ -1,748 +0,0 @@ -# 类与类加载 - -前面我们讲解了JVM的内存结构,包括JVM如何对内存进行划分,如何对内存区域进行垃圾回收。接下来,我们来研究一下类文件结构以及类的加载机制。 - -## 类文件结构 - -在我们学习C语言的时候,我们的编程过程会经历如下几个阶段:写代码、保存、编译、运行。实际上,最关键的一步是编译,因为只有经历了编译之后,我们所编写的代码才能够翻译为机器可以直接运行的二进制代码,并且在不同的操作系统下,我们的代码都需要进行一次编译之后才能运行。 - -> 如果全世界所有的计算机指令集只有x86一种,操作系统只有Windows一种,那也许就不会有Java语言的出现。 - -随着时代的发展,人们迫切希望能够在不同的操作系统、不同的计算机架构中运行同一套编译之后的代码。本地代码不应该是我们编程的唯一选择,所以,越来越多的语言选择了与操作系统和机器指令集无关的中立格式作为编译后的存储格式。 - -“一次编写,到处运行”,Java最引以为傲的口号,标志着平台不再是限制编程语言的阻碍。 - -实际上,Java正式利用了这样的解决方案,将源代码编译为平台无关的中间格式,并通过对应的Java虚拟机读取和运行这些中间格式的编译文件,这样,我们只需要考虑不同平台的虚拟机如何编写,而Java语言本身很轻松地实现了跨平台。 - -现在,越来越多的开发语言都支持将源代码编译为`.class`字节码文件格式,以便能够直接交给JVM运行,包括Kotlin(安卓开发官方指定语言)、Groovy、Scala等。 - -![image-20220223162914535](https://tva1.sinaimg.cn/large/e6c9d24egy1gznizn2l97j21qc0jedgq.jpg) - -那么,让我们来看看,我们的源代码编译之后,是如何保存在字节码文件中的。 - -*** - -### 类文件信息 - -我们之前都是使用`javap`命令来对字节码文件进行反编译查看的,那么,它以二进制格式是怎么保存呢?我们可以使用WinHex软件(Mac平台可以使用[010 Editor](https://www.macwk.com/soft/010-editor))来以十六进制查看字节码文件。 - -```java -public class Main { - public static void main(String[] args) { - int i = 10; - int a = i++; - int b = ++i; - } -} -``` - -找到我们在IDEA中编译出来的class文件,将其拖动进去: - -![image-20220223164725971](https://tva1.sinaimg.cn/large/e6c9d24egy1gznjij4fgpj21800u011h.jpg) - -可以看到整个文件中,全是一个字节一个字节分组的样子,从左上角开始,一行一行向下读取。可以看到在右侧中还出现了一些我们之前也许见过的字符串,比如""、"Object"等。 - -实际上Class文件采用了一种类似于C中结构体的伪结构来存储数据(当然我们直接看是看不出来的),但是如果像这样呢? - -``` -Classfile /Users/nagocoler/Develop.localized/JavaHelloWorld/target/classes/com/test/Main.class - Last modified 2022-2-23; size 444 bytes - MD5 checksum 8af3e63f57bcb5e3d0eec4b0468de35b - Compiled from "Main.java" -public class com.test.Main - minor version: 0 - major version: 52 - flags: ACC_PUBLIC, ACC_SUPER -Constant pool: - #1 = Methodref #3.#21 // java/lang/Object."":()V - #2 = Class #22 // com/test/Main - #3 = Class #23 // java/lang/Object - #4 = Utf8 - #5 = Utf8 ()V - #6 = Utf8 Code - #7 = Utf8 LineNumberTable - #8 = Utf8 LocalVariableTable - #9 = Utf8 this - #10 = Utf8 Lcom/test/Main; - #11 = Utf8 main - #12 = Utf8 ([Ljava/lang/String;)V - #13 = Utf8 args - #14 = Utf8 [Ljava/lang/String; - #15 = Utf8 i - #16 = Utf8 I - #17 = Utf8 a - #18 = Utf8 b - #19 = Utf8 SourceFile - #20 = Utf8 Main.java - #21 = NameAndType #4:#5 // "":()V - #22 = Utf8 com/test/Main - #23 = Utf8 java/lang/Object -{ - public com.test.Main(); - descriptor: ()V - flags: ACC_PUBLIC - Code: - stack=1, locals=1, args_size=1 - 0: aload_0 - 1: invokespecial #1 // Method java/lang/Object."":()V - 4: return - LineNumberTable: - line 11: 0 - LocalVariableTable: - Start Length Slot Name Signature - 0 5 0 this Lcom/test/Main; - - public static void main(java.lang.String[]); - descriptor: ([Ljava/lang/String;)V - flags: ACC_PUBLIC, ACC_STATIC - Code: - stack=1, locals=4, args_size=1 - 0: bipush 10 - 2: istore_1 - 3: iload_1 - 4: iinc 1, 1 - 7: istore_2 - 8: iinc 1, 1 - 11: iload_1 - 12: istore_3 - 13: return - LineNumberTable: - line 13: 0 - line 14: 3 - line 15: 8 - line 16: 13 - LocalVariableTable: - Start Length Slot Name Signature - 0 14 0 args [Ljava/lang/String; - 3 11 1 i I - 8 6 2 a I - 13 1 3 b I -} -SourceFile: "Main.java" -``` - -乍一看,是不是感觉还真的有点结构体那味? - -而结构体中,有两种允许存在的数据类型,一个是无符号数,还有一个是表。 - -* 无符号数一般是基本数据类型,用u1、u2、u4、u8来表示,表示1个字节~8个字节的无符号数。可以表示数字、索引引用、数量值或是以UTF-8编码格式的字符串。 -* 表包含多个无符号数,并且以"_info"结尾。 - -我们首先从最简的开始看起。 - -![image-20220223164126100](https://tva1.sinaimg.cn/large/e6c9d24egy1gznjcb9bipj21ro0iutfs.jpg) - -首先,我们可以看到,前4个字节(共32位)组成了魔数(其实就是表示这个文件是一个JVM可以运行的字节码文件,除了Java以外,其他某些文件中也采用了这种魔数机制来进行区分,这种方式比直接起个文件扩展名更安全) - -字节码文件的魔数为:CAFEBABE(这名字能想出来也是挺难的了,毕竟4个bit位只能表示出A-F这几个字母) - -紧接着魔数的后面4个字节存储的是字节码文件的版本号,注意前两个是次要版本号(现在基本都不用了,都是直接Java8、Java9这样命名了),后面两个是主要版本号,这里我们主要看主版本号,比如上面的就是34,注意这是以16进制表示的,我们把它换算为10进制后,得到的结果为:`34 -> 3*16 + 4 = 52`,其中`52`代表的是`JDK8`编译的字节码文件(51是JDK7、50是JDK6、53是JDK9,以此类推) - -JVM会根据版本号决定是否能够运行,比如JDK6只能支持版本号为1.1~6的版本,也就是说必须是Java6之前的环境编译出来的字节码文件,否则无法运行。又比如我们现在安装的是JDK8版本,它能够支持的版本号为1.1~8,那么如果这时我们有一个通过Java7编译出来的字节码文件,依然是可以运行的,所以说Java版本是向下兼容的。 - -紧接着,就是类的常量池了,这里面存放了类中所有的常量信息(注意这里的常量并不是指我们手动创建的final类型常量,而是程序运行一些需要用到的常量数据,比如字面量和符号引用等)由于常量的数量不是确定的,所以在最开始的位置会存放常量池中常量的数量(是从1开始计算的,不是0,比如这里是18,翻译为10进制就是24,所以实际上有23个常量) - -接着再往下,就是常量池里面的数据了,每一项常量池里面的数据都是一个表,我们可以看到他们都是以_info结尾的: - -![image-20220223171746645](https://tva1.sinaimg.cn/large/e6c9d24egy1gznkh0jr31j21800u07dm.jpg) - -我们来看看一个表中定义了哪些内容: - -![image-20220223172031889](https://tva1.sinaimg.cn/large/e6c9d24egy1gznkh14d4rj21b805wt9v.jpg) - -首先上来就会有一个1字节的无符号数,它用于表示当前常量的类型(常量类型有很多个)这里只列举一部分的类型介绍: - -| 类型 | 标志 | 描述 | -| :-----------------------: | :--: | :----------------------------------------------------------: | -| CONSTANT_Utf8_info | 1 | UTF-8编码格式的字符串 | -| CONSTANT_Integer_info | 3 | 整形字面量(第一章我们演示的很大的数字,实际上就是以字面量存储在常量池中的) | -| CONSTANT_Class_info | 7 | 类或接口的符号引用 | -| CONSTANT_String_info | 8 | 字符串类型的字面量 | -| CONSTANT_Fieldref_info | 9 | 字段的符号引用 | -| CONSTANT_Methodref_info | 10 | 方法的符号引用 | -| CONSTANT_MethodType_info | 16 | 方法类型 | -| CONSTANT_NameAndType_info | 12 | 字段或方法的部分符号引用 | - -实际上这些东西,虽然我们不知道符号引用是什么东西,我们可以观察出来,这些东西或多或少都是存放类中一些名称、数据之类的东西。 - -比如我们来看第一个`CONSTANT_Methodref_info`表中存放了什么数据,这里我只列出它的结构表(详细的结构表可以查阅《深入理解Java虚拟机 第三版》中222页总表): - -| 常量 | 项目 | 类型 | 描述 | -| :---------------------: | :---: | :--: | :-------------------------------------------------: | -| CONSTANT_Methodref_info | tag | u1 | 值为10 | -| | index | u2 | 指向声明方法的类描述父CONSTANT_Class_info索引项 | -| | index | u2 | 指向名称及类型描述符CONSTANT_NameAndType_info索引项 | - -比如我们刚刚的例子中: - -![image-20220223190659053](https://tva1.sinaimg.cn/large/e6c9d24ely1gznnkpf7cqj21b40503zi.jpg) - -可以看到,第一个索引项指向了第3号常量,我们来看看三号常量: - -![image-20220223190957382](https://tva1.sinaimg.cn/large/e6c9d24ely1gznnmsuh1pj219w03amxj.jpg) - -| 常量 | 项目 | 类型 | 描述 | -| :-----------------: | :---: | :--: | :----------------------: | -| CONSTANT_Class_info | tag | u1 | 值为7 | -| | index | u2 | 指向全限定名常量项的索引 | - -那么我们接着来看23号常量又写的啥: - -![image-20220223191325689](https://tva1.sinaimg.cn/large/e6c9d24ely1gznnqfknqaj21fo0j6te5.jpg) - -可以看到指向的UTF-8字符串值为`java/lang/Object`这下搞明白了,首先这个方法是由Object类定义的,那么接着我们来看第二项u2 `name_and_type_index`,指向了21号常量,也就是字段或方法的部分符号引用: - -![image-20220223191921550](https://tva1.sinaimg.cn/large/e6c9d24ely1gzno0zakf9j21eg0qyqbl.jpg) - -| 常量 | 项目 | 类型 | 描述 | -| :-----------------------: | :---: | :--: | :------------------------------: | -| CONSTANT_NameAndType_info | tag | u1 | 值为12 | -| | index | u2 | 指向字段或方法名称常量项的索引 | -| | index | u2 | 指向字段或方法描述符常量项的索引 | - -其中第一个索引就是方法的名称,而第二个就是方法的描述符,描述符明确了方法的参数以及返回值类型,我们分别来看看4号和5号常量: - -![image-20220223192332068](https://tva1.sinaimg.cn/large/e6c9d24ely1gzno0z1yp1j21eg0qyqbl.jpg) - -可以看到,方法名称为"",一般构造方法的名称都是,普通方法名称是什么就是什么,方法描述符为"()V",表示此方法没有任何参数,并且返回值类型为void,描述符对照表如下: - -![image-20220223192518999](https://tva1.sinaimg.cn/large/e6c9d24ely1gzno2stssaj216i08mjsr.jpg) - -比如这里有一个方法`public int test(double a, char c){ ... }`,那么它的描述符就应该是:`(DC)I`,参数依次放入括号中,括号右边是返回值类型。再比如`public String test(Object obj){ ... }`,那么它的描述符就应该是:`(Ljava/lang/Object;)Ljava/lang/String`,注意如果参数是对象类型,那么必须在后面添加`;` - -对于数组类型,只需要在类型最前面加上`[`即可,有几个维度,就加几个,比如`public void test(int[][] arr)`,参数是一个二维int类型数组,那么它的描述符为:`([[I)V` - -所以,这里表示的,实际上就是此方法是一个无参构造方法,并且是属于Object类的。那么,为什么这里需要Object类构造方法的符号引用呢?还记得我们在JavaSE中说到的,每个类都是直接或间接继承自Object类,所有类的构造方法,必须先调用父类的构造方法,但是如果父类存在无参构造,默认可以不用显示调用`super`关键字(当然本质上是调用了的)。 - -所以说,当前类因为没有继承自任何其他类,那么就默认继承的Object类,所以,在当前类的默认构造方法中,调用了父类Object类的无参构造方法,因此这里需要符号引用的用途显而易见,就是因为需要调用Object类的无参构造方法。 - -我们可以在反编译结果中的方法中看到: - -``` -public com.test.Main(); - descriptor: ()V - flags: ACC_PUBLIC - Code: - stack=1, locals=1, args_size=1 - 0: aload_0 - 1: invokespecial #1 // Method java/lang/Object."":()V - 4: return - LineNumberTable: - line 11: 0 - LocalVariableTable: - Start Length Slot Name Signature - 0 5 0 this Lcom/test/Main; -``` - -其中`invokespecial`(调用父类构造方法)指令的参数指向了1号常量,而1号常量正是代表的Object类的无参构造方法,虽然饶了这么大一圈,但是过程理清楚,还是很简单的。 - -虽然我们可以直接查看16进制的结果,但是还是不够方便,但是我们也不能每次都去使用`javap`命令,所以我们这里安装一个IDEA插件,来方便我们查看字节码中的信息,名称为`jclasslib Bytecode Viewer` : - -![image-20220223194128297](https://tva1.sinaimg.cn/large/e6c9d24ely1gznojlqgl3j216y0dc0u0.jpg) - -安装完成后,我们可以在我们的IDEA右侧看到它的板块,但是还没任何数据,那么比如现在我们想要查看Main类的字节码文件时,可以这样操作: - -![image-20220223194410699](https://tva1.sinaimg.cn/large/e6c9d24ely1gznomfiqu8j22ll0u0tfa.jpg) - -首先在项目中选中我们的Main类,然后点击工具栏的视图,然后点击`Show Bytecode With Jclasslib`,这样右侧就会出现当前类的字节码解析信息了。注意如果修改了类的话,那么需要你点击运行或是构建,然后点击刷新按钮来进行更新。 - -接着我们来看下一个内容,在常量池之后,紧接着就是访问标志,访问标志就是类的种类以及类上添加的一些关键字等内容: - -![image-20220223194942810](https://tva1.sinaimg.cn/large/e6c9d24ely1gznos6c7j9j21e60giq7s.jpg) - -可以看到它只占了2个字节,那么它是如何表示访问标志呢? - -![image-20220223200619811](https://tva1.sinaimg.cn/large/e6c9d24ely1gznp9glonej216i0hcjui.jpg) - -比如我们这里的Main类,它是一个普通的class类型,并且访问权限为public,那么它的访问标志值是这样计算的: - -`ACC_PUBLIC | ACC_SUPER = 0x0001 | 0x0020 = 0x0021`(这里进行的是按位或运算),可以看到和我们上面的结果是一致的。 - -再往下就是类索引、父类索引、接口索引: - -![image-20220223200054866](https://tva1.sinaimg.cn/large/e6c9d24ely1gznp3uofdej219803q0t7.jpg) - -可以看到它们的值也是指向常量池中的值,其中2号常量正是存储的当前类信息,3号常量存储的是父类信息,这里就不再倒推回去了,由于没有接口,所以这里接口数量为0,如果不为0还会有一个索引表来引用接口。 - -接着就是字段和方法表集合了: - -![image-20220223200521912](https://tva1.sinaimg.cn/large/e6c9d24ely1gznp8gd1nfj21ai04mdgp.jpg) - -由于我们这里没有声明任何字段,所以我们先给Main类添加一个字段再重新加载一下: - -```java -public class Main { - - public static int a = 10; - - public static void main(String[] args) { - int i = 10; - int a = i++; - int b = ++i; - } -} -``` - -![image-20220223200733342](https://tva1.sinaimg.cn/large/e6c9d24ely1gznpbh3k7rj21bi06o3zn.jpg) - -现在字节码就新增了一个字段表,这个字段表实际上就是我们刚刚添加的成员字段`a`的数据。 - -可以看到一共有四个2字节的数据: - -![image-20220223200939786](https://tva1.sinaimg.cn/large/e6c9d24ely1gznpcxjzgfj216o06et9o.jpg) - -首先是`access_flags`,这个与上面类标志的计算规则是一样的,表还是先列出来吧: - -![image-20220223201053780](https://tva1.sinaimg.cn/large/e6c9d24ely1gznpe7is4wj21620eswh4.jpg) - -第二个数据`name_index`表示字段的名称常量,这里指向的是5号常量,那么我们来看看5号常量是不是字段名称: - -![image-20220223201327180](https://tva1.sinaimg.cn/large/e6c9d24ely1gznpgw09wjj21bc0tuk0x.jpg) - -没问题,这里就是`a`,下一个是`descirptor_index`,存放的是描述符,不过这里因为不是方法而是变量,所以描述符直接写对应类型的标识字符即可,比如这里是`int`类型,那么就是`I`。 - -最后,`attrbutes_count`属性计数器,用于描述一些额外信息,这里我们暂时不做介绍。 - -接着就是我们的方法表了: - -![image-20220223202153955](https://tva1.sinaimg.cn/large/e6c9d24ely1gznppnxpcqj21ai04odgx.jpg) - -可以看到方法表中一共有三个方法,其中第一个方法我们刚刚已经介绍过了,它的方法名称为``,表示它是一个构造方法,我们看到最后一个方法名称为``,这个是类在初始化时会调用的方法(是隐式的,自动生成的),它主要是用于静态变量初始化语句和静态块的执行,因为我们这里给静态成员变量a赋值为10,所以会在一开始为其赋值: - -![image-20220223202515287](https://tva1.sinaimg.cn/large/e6c9d24ely1gznpt5dhg3j224c0katcg.jpg) - -而第二个方法,就是我们的`main`方法了,但是现在我们先不急着去看它的详细实现过程,我们来看看它的属性表。 - -属性表实际上类中、字段中、方法中都可以携带自己的属性表,属性表存放的正是我们的代码、本地变量等数据,比如main方法就存在4个本地变量,那么它的本地变量存放在哪里呢: - -![image-20220223202955858](https://tva1.sinaimg.cn/large/e6c9d24ely1gznpy0i9ehj21by0hywii.jpg) - -可以看到,属性信息呈现套娃状态,在此方法中的属性包括了一个Code属性,此属性正是我们的Java代码编译之后变成字节码指令,然后存放的地方,而在此属性中,又嵌套了本地变量表和源码行号表。 - -可以看到code中存放的就是所有的字节码指令: - -![image-20220223203241262](https://tva1.sinaimg.cn/large/e6c9d24ely1gznq0wqe4xj215a0bi76l.jpg) - -这里我们暂时不对字节码指令进行讲解(其实也用不着讲了,都认识的差不多了)。我们接着来看本地变量表,这里存放了我们方法中要用到的局部变量: - -![image-20220223203356129](https://tva1.sinaimg.cn/large/e6c9d24ely1gznq26f7rhj219w0ekq5v.jpg) - -可以看到一共有四个本地变量,而第一个变量正是main方法的形参`String[] args`,并且表中存放了本地变量的长度、名称、描述符等内容。当然,除了我们刚刚认识的这几个属性之外,完整属性可以查阅《深入理解Java虚拟机 第三版》231页。 - -最后,类也有一些属性: - -![image-20220223203835282](https://tva1.sinaimg.cn/large/e6c9d24ely1gznq712n66j21dw0n20xw.jpg) - -此属性记录的是源文件名称。 - -这样,我们对一个字节码文件的认识差不多就结束了,在了解了字节码文件的结构之后,是不是感觉豁然开朗? - -*** - -### 字节码指令 - -虚拟机的指令是由一个字节长度的、代表某种特定操作含义的数字(操作码,类似于机器语言),操作后面也可以携带0个或多个参数一起执行。我们前面已经介绍过了,JVM实际上并不是面向寄存器架构的,而是面向操作数栈,所以大多数指令都是不带参数的。 - -由于之前已经讲解过大致运行流程,这里我们就以当前的Main类中的main方法作为教材进行讲解: - -```java -public static void main(String[] args) { - int i = 10; - int a = i++; - int b = ++i; -} -``` - -可以看到,main方法中首先是定义了一个int类型的变量i,并赋值为10,然后变量a接收`i++`的值,变量b接收`++i`的值。 - -那么我们来看看编译成字节码之后,是什么样的: - -![image-20220223205928901](https://tva1.sinaimg.cn/large/e6c9d24ely1gznqsryzgfj225c0lgq6o.jpg) - -* 首先第一句,`bipush`,将10送至操作数栈顶。 -* 接下来将操作数栈顶的数值存进1号本地变量,也就是变量i中。 -* 接着将变量i中的值又丢向操作数栈顶 -* 这里使用`iinc`指令,将1号本地变量的值增加1(结束之后i的值就是11了) -* 接着将操作数栈顶的值(操作数栈顶的值是10)存入2号本地变量(这下彻底知道i++到底干了啥才会先返回后自增了吧,从原理角度来说,实际上i是先自增了的,但由于这里取的是操作数栈中的值,所以说就得到了i之前的值) -* 接着往下,我们看到++i是先直接将i的值自增1 -* 然后在将其值推向操作数栈顶 - -![image-20220223214441621](https://tva1.sinaimg.cn/large/e6c9d24ely1gzns3syhe7j21x8090q5k.jpg) - -而从结果来看,`i++`操作确实是先返回再自增的,而字节码指令层面来说,却是截然相反的,只是结果一致罢了。 - -*** - -### ASM字节码编程 - -既然字节码文件结构如此清晰,那么我们能否通过编程,来直接创建一个字节码文件呢?如果我们可以直接编写一个字节码文件,那么我们就可以省去编译的过程。ASM(某些JDK中内置)框架正是用于支持字节码编程的框架。 - -比如现在我们需要创建一个普通的Main类(暂时不写任何内容) - -首先我们来看看如何通过编程创建一个Main类的字节码文件: - -```java -public class Main { - public static void main(String[] args) { - ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); - } -} -``` - -首先需要获取`ClassWriter`对象,我们可以使用它来编辑类的字节码文件,在构造时需要传入参数: - -* 0 这种方式不会自动计算操作数栈和局部临时变量表大小,需要自己手动来指定 -* ClassWriter.COMPUTE_MAXS(1) 这种方式会自动计算上述操作数栈和局部临时变量表大小,但需要手动触发。 -* ClassWriter.COMPUTE_FRAMES(2) 这种方式不仅会计算上述操作数栈和局部临时变量表大小,而且会自动计算StackMapFrames - -这里我们使用`ClassWriter.COMPUTE_MAXS`即可。 - -接着我们首先需要指定类的一些基本信息: - -```java -public class Main { - public static void main(String[] args) { - ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); - //因为这里用到的常量比较多,所以说直接一次性静态导入:import static jdk.internal.org.objectweb.asm.Opcodes.*; - writer.visit(V1_8, ACC_PUBLIC,"com/test/Main", null, "java/lang/Object",null); - } -} -``` - -这里我们将字节码文件的版本设定位Java8,然后修饰符设定为`ACC_PUBLIC`代表`public class Main`,类名称注意要携带包名,标签设置为`null`,父类设定为Object类,然后没有实现任何接口,所以说最后一个参数也是`null`。 - -接着,一个简答的类字节码文件就创建好了,我们可以尝试将其进行保存: - -```java -public class Main { - public static void main(String[] args) { - ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); - writer.visit(V1_8, ACC_PUBLIC,"com/test/Main", null, "java/lang/Object",null); - //调用visitEnd表示结束编辑 - writer.visitEnd(); - - try(FileOutputStream stream = new FileOutputStream("./Main.class")){ - stream.write(writer.toByteArray()); //直接通过ClassWriter将字节码文件转换为byte数组,并保存到根目录下 - } catch (IOException e) { - e.printStackTrace(); - } - } -} -``` - -可以看到,在IDEA中反编译的结果为: - -```java -package com.test; - -public class Main { -} -``` - -我们知道,正常的类在编译之后,如果没有手动添加构造方法,那么会自带一个无参构造,但是我们这个类中还没有,所以我们来手动添加一个无参构造方法: - -```java -//通过visitMethod方法可以添加一个新的方法 -writer.visitMethod(ACC_PUBLIC, "", "()V", null, null); -``` - -可以看到反编译的结果中已经存在了我们的构造方法: - -```java -package com.test; - -public class Main { - public Main() { - } -} -``` - -但是这样是不合法的,因为我们的构造方法还没有添加父类构造方法调用,所以说我们还需要在方法中添加父类构造方法调用指令: - -``` -public com.test.Main(); - descriptor: ()V - flags: ACC_PUBLIC - Code: - stack=1, locals=1, args_size=1 - 0: aload_0 - 1: invokespecial #1 // Method java/lang/Object."":()V - 4: return - LineNumberTable: - line 11: 0 - LocalVariableTable: - Start Length Slot Name Signature - 0 5 0 this Lcom/test/Main; -``` - -我们需要对方法进行详细编辑: - -```java -//通过MethodVisitor接收返回值,进行进一步操作 -MethodVisitor visitor = writer.visitMethod(ACC_PUBLIC, "", "()V", null, null); -//开始编辑代码 -visitor.visitCode(); - -//Label用于存储行号 -Label l1 = new Label(); -//当前代码写到哪行了,l1得到的就是多少行 -visitor.visitLabel(l1); -//添加源码行数对应表(其实可以不用) -visitor.visitLineNumber(11, l1); - -//注意不同类型的指令需要用不同方法来调用,因为操作数不一致,具体的注释有写 -visitor.visitVarInsn(ALOAD, 0); -visitor.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "", "()V", false); -visitor.visitInsn(RETURN); - -Label l2 = new Label(); -visitor.visitLabel(l2); -//添加本地变量表,这里加的是this关键字,但是方法中没用到,其实可以不加 -visitor.visitLocalVariable("this", "Lcom/test/Main;", null, l1, l2, 0); - -//最后设定最大栈深度和本地变量数 -visitor.visitMaxs(1, 1); -//结束编辑 -visitor.visitEnd(); -``` - -我们可以对编写好的class文件进行反编译,看看是不是和IDEA编译之后的结果差不多: - -``` -{ - public com.test.Main(); - descriptor: ()V - flags: ACC_PUBLIC - Code: - stack=1, locals=1, args_size=1 - 0: aload_0 - 1: invokespecial #8 // Method java/lang/Object."":()V - 4: return - LocalVariableTable: - Start Length Slot Name Signature - 0 5 0 this Lcom/test/Main - LineNumberTable: - line 11: 0 -} -``` - -可以看到和之前的基本一致了,到此为止我们构造方法就编写完成了,接着我们来写一下main方法,一会我们就可以通过main方法来运行Java程序了。比如我们要编写这样一个程序: - -```java -public static void main(String[] args) { - int a = 10; - System.out.println(a); -} -``` - -看起来很简单的一个程序对吧,但是我们如果手动去组装指令,会极其麻烦!首先main方法是一个静态方法,并且方法是public权限,然后还有一个参数`String[] args`,所以说我们这里要写的内容有点小多: - -```java -//开始安排main方法 -MethodVisitor v2 = writer.visitMethod(ACC_PUBLIC | ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null); -v2.visitCode(); -//记录起始行信息 -Label l3 = new Label(); -v2.visitLabel(l3); -v2.visitLineNumber(13, l3); - -//首先是int a = 10的操作,执行指令依次为: -// bipush 10 将10推向操作数栈顶 -// istore_1 将操作数栈顶元素保存到1号本地变量a中 -v2.visitIntInsn(BIPUSH, 10); -v2.visitVarInsn(ISTORE, 1); -Label l4 = new Label(); -v2.visitLabel(l4); -//记录一下行信息 -v2.visitLineNumber(14, l4); - -//这里是获取System类中的out静态变量(PrintStream接口),用于打印 -v2.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); -//把a的值取出来 -v2.visitVarInsn(ILOAD, 1); -//调用接口中的抽象方法println -v2.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(I)V", false); - -//再次记录行信息 -Label l6 = new Label(); -v2.visitLabel(l6); -v2.visitLineNumber(15, l6); - -v2.visitInsn(RETURN); -Label l7 = new Label(); -v2.visitLabel(l7); - -//最后是本地变量表中的各个变量 -v2.visitLocalVariable("args", "[Ljava/lang/String;", null, l3, l7, 0); -v2.visitLocalVariable("a", "I", null, l4, l7, 1); -v2.visitMaxs(1, 2); -//终于OK了 -v2.visitEnd(); -``` - -可以看到,虽然很简单的一个程序,但是如果我们手动去编写字节码,实际上是非常麻烦的,但是要实现动态代理之类的操作(可以很方便地修改字节码创建子类),是不是感觉又Get到了新操作(其实Spring实现动态代理的CGLib框架底层正是调用了ASM框架来实现的),所以说了解一下还是可以的,不过我们自己肯定是没多少玩这个的机会了。 - -*** - -## 类加载机制 - -现在,我们已经了解了字节码文件的结构,以及JVM如何对内存进行管理,现在只剩下最后一个谜团等待解开了,也就是我们的类字节码文件到底是如何加载到内存中的,加载之后又会做什么事情。 - -### 类加载过程 - -首先,要加载一个类,一定是出于某种目的的,比如我们要运行我们的Java程序,那么就必须要加载主类才能运行主类中的主方法,又或是我们需要加载数据库驱动,那么可以通过反射来将对应的数据库驱动类进行加载。 - -所以,一般在这些情况下,如果类没有被加载,那么会被自动加载: - -* 使用new关键字创建对象时 -* 使用某个类的静态成员(包括方法和字段)的时候(当然,final类型的静态字段有可能在编译的时候被放到了当前类的常量池中,这种情况下是不会触发自动加载的) -* 使用反射对类信息进行获取的时候(之前的数据库驱动就是这样的) -* 加载一个类的子类时 -* 加载接口的实现类,且接口带有`default`的方法默认实现时 - -比如这种情况,那么需要用到另一个类中的成员字段,所以就必须将另一个类加载之后才能访问: - -```java -public class Main { - public static void main(String[] args) { - System.out.println(Test.str); - } - - public static class Test{ - static { - System.out.println("我被初始化了!"); - } - - public static String str = "都看到这里了,不给个三连+关注吗?"; - } -} -``` - -这里我们就演示一个不太好理解的情况,我们现在将静态成员变量修改为final类型的: - -```java -public class Main { - public static void main(String[] args) { - System.out.println(Test.str); - } - - public static class Test{ - static { - System.out.println("我被初始化了!"); - } - - public final static String str = "都看到这里了,不给个三连+关注吗?"; - } -} -``` - -可以看到,在主方法中,我们使用了Test类的静态成员变量,并且此静态成员变量是一个final类型的,也就是说不可能再发生改变。那么各位觉得,Test类会像上面一样被初始化吗? - -按照正常逻辑来说,既然要用到其他类中的字段,那么肯定需要加载其他类,但是这里我们结果发现,并没有对Test类进行加载,那么这是为什么呢?我们来看看Main类编译之后的字节码指令就知道了: - -![image-20220224131511381](https://tva1.sinaimg.cn/large/e6c9d24ely1gzoizzv7azj227c0lcjvp.jpg) - -很明显,这里使用的是`ldc`指令从常量池中将字符串取出并推向操作数栈顶,也就是说,在编译阶段,整个`Test.str`直接被替换为了对应的字符串(因为final不可能发生改变的,编译就会进行优化,直接来个字符串比你去加载类在获取快得多不是吗,反正结果都一样),所以说编译之后,实际上跟Test类半毛钱关系都没有了。 - -所以说,当你在某些情况下疑惑为什么类加载了或是没有加载时,可以从字节码指令的角度去进行分析,一般情况下,只要遇到`new`、`getstatic`、`putstatic`、`invokestatic`这些指令时,都会进行类加载,比如: - -![image-20220224132029992](https://tva1.sinaimg.cn/large/e6c9d24ely1gzoj5isswmj22520j877u.jpg) - -这里很明显,是一定会将Test类进行加载的。除此之外,各位也可以试试看数组的定义会不会导致类被加载。 - -好了,聊完了类的加载触发条件,我们接着来看一下类的详细加载流程。 - -![image-20220224132621764](https://tva1.sinaimg.cn/large/e6c9d24ely1gzojblu4woj21380jkjtf.jpg) - -首先类的生命周期一共有7个阶段,而首当其冲的就是加载,加载阶段需要获取此类的二进制数据流,比如我们要从硬盘中读取一个class文件,那么就可以通过文件输入流来获取类文件的`byte[]`,也可以是其他各种途径获取类文件的输入流,甚至网络传输并加载一个类也不是不可以。然后交给类加载器进行加载(类加载器可以是JDK内置的,也可以是开发者自己撸的,后面会详细介绍)类的所有信息会被加载到方法区中,并且在堆内存中会生成一个代表当前类的Class类对象(那么思考一下,同一个Class文件加载的类,是唯一存在的吗?),我们可以通过此对象以及反射机制来访问这个类的各种信息。 - -数组类要稍微特殊一点,通过前面的检验,我没发现数组在创建后是不会导致类加载的,数组类型本身不会通过类加载器进行加载的,不过你既然要往里面丢对象进去,那最终依然是要加载类的。 - -接着我们来看验证阶段,验证阶段相当于是对加载的类进行一次规范校验(因为一个类并不一定是由我们使用IDEA编译出来的,有可能是像我们之前那样直接用ASM框架写的一个),如果说类的任何地方不符合虚拟机规范,那么这个类是不会验证通过的,如果没有验证机制,那么一旦出现危害虚拟机的操作,整个程序会出现无法预料的后果。 - -验证阶段,首先是文件格式的验证: - -* 是否魔数为CAFEBABE开头。 -* 主、次版本号是否可以由当前Java虚拟机运行 -* Class文件各个部分的完整性如何。 -* ... - -有关类验证的详细过程,可以参考《深入理解Java虚拟机 第三版》268页。 - -接下来就是准备阶段了,这个阶段会为类变量分配内存,并为一些字段设定初始值,注意是系统规定的初始值,不是我们手动指定的初始值。 - -再往下就是解析阶段,此阶段是将常量池内的符号引用替换为直接引用的过程,也就是说,到这个时候,所有引用变量的指向都是已经切切实实地指向了内存中的对象了。 - -到这里,链接过程就结束了,也就是说这个时候类基本上已经完成大部分内容的初始化了。 - -最后就是真正的初始化阶段了,从这里开始,类中的Java代码部分,才会开始执行,还记得我们之前介绍的``方法吗,它就是在这个时候执行的,比如我们的类中存在一个静态成员变量,并且赋值为10,或是存在一个静态代码块,那么就会自动生成一个``方法来进行赋值操作,但是这个方法是自动生成的。 - -全部完成之后,我们的类就算是加载完成了。 - -*** - -### 类加载器 - -Java提供了类加载器,以便我们自己可以更好地控制类加载,我们可以自定义类加载器,也可以使用官方自带的类加载器去加载类。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性。 - -也就是说,一个类可以由不同的类加载器加载,并且,不同的类加载器加载的出来的类,即使来自同一个Class文件,也是不同的,只有两个类来自同一个Class文件并且是由同一个类加载器加载的,才能判断为是同一个。默认情况下,所有的类都是由JDK自带的类加载器进行加载。 - -比如,我们先创建一个Test类用于测试: - -```java -package com.test; - -public class Test { - -} -``` - -接着我们自己实现一个ClassLoader来加载我们的Test类,同时使用官方默认的类加载器来加载: - -```java -public class Main { - public static void main(String[] args) throws ReflectiveOperationException { - Class testClass1 = Main.class.getClassLoader().loadClass("com.test.Test"); - CustomClassLoader customClassLoader = new CustomClassLoader(); - Class testClass2 = customClassLoader.loadClass("com.test.Test"); - - //看看两个类的类加载器是不是同一个 - System.out.println(testClass1.getClassLoader()); - System.out.println(testClass2.getClassLoader()); - - //看看两个类是不是长得一模一样 - System.out.println(testClass1); - System.out.println(testClass2); - - //两个类是同一个吗? - System.out.println(testClass1 == testClass2); - - //能成功实现类型转换吗? - Test test = (Test) testClass2.newInstance(); - } - - static class CustomClassLoader extends ClassLoader { - @Override - public Class loadClass(String name) throws ClassNotFoundException { - try (FileInputStream stream = new FileInputStream("./target/classes/"+name.replace(".", "/")+".class")){ - byte[] data = new byte[stream.available()]; - stream.read(data); - if(data.length == 0) return super.loadClass(name); - return defineClass(name, data, 0, data.length); - } catch (IOException e) { - return super.loadClass(name); - } - } - } -} -``` - -通过结果我们发现,即使两个类是同一个Class文件加载的,只要类加载器不同,那么这两个类就是不同的两个类。 - -所以说,我们当时在JavaSE阶段讲解的每个类都在堆中有一个唯一的Class对象放在这里来看,并不完全正确,只是当前为了防止各位初学者搞混。 - -实际上,JDK内部提供的类加载器一共有三个,比如上面我们的Main类,其实是被AppClassLoader加载的,而JDK内部的类,都是由BootstrapClassLoader加载的,这其实就是为了实现双亲委派机制而做的。 - -![image-20220225132629954](https://tva1.sinaimg.cn/large/e6c9d24ely1gzpoy41z31j20wb0u040w.jpg) - -有关双亲委派机制,我们在JavaSE阶段反射板块已经讲解过了,所以说这就不多做介绍了。 diff --git a/青空笔记/JVM笔记/JVM笔记(二).md b/青空笔记/JVM笔记/JVM笔记(二).md deleted file mode 100644 index 0a6b158..0000000 --- a/青空笔记/JVM笔记/JVM笔记(二).md +++ /dev/null @@ -1,1091 +0,0 @@ -# JVM内存管理 - -在之前,我们了解了JVM的大致运作原理以及相关特性,这一章,我们首先会从内存管理说起。 - -在传统的C/C++开发中,我们经常通过使用申请内存的方式来创建对象或是存放某些数据,但是这样也带来了一些额外的问题,我们要在何时释放这些内存,怎么才能使得内存的使用最高效,因此,内存管理是一个非常严肃的问题。 - -比如我们就可以通过C语言动态申请内存,并用于存放数据: - -```c -#include -#include - -int main(){ - //动态申请4个int大小的内存空间 - int* memory = malloc(sizeof(int) * 4); - //修改第一个int空间的值 - memory[0] = 10; - //修改第二个int空间的值 - memory[1] = 2; - //遍历内存区域中所有的值 - for (int i = 0;i < 4;i++){ - printf("%d ", memory[i]); - } - //释放指针所指向的内存区域 - free(memory); - //最后将指针赋值为NULL - memory = NULL; -} -``` - -而在Java中,这种操作实际上是不允许的,Java只支持直接使用基本数据类型和对象类型,至于内存到底如何分配,并不是由我们来处理,而是JVM帮助我们进行控制,这样就帮助我们节省很多内存上的工作,虽然带来了很大的便利,但是,一旦出现内存问题,我们就无法像C/C++那样对所管理的内存进行合理地处理,因为所有的内存操作都是由JVM在进行,只有了解了JVM的内存管理机制,我们才能够在出现内存相关问题时找到解决方案。 - -## 内存区域划分 - -既然要管理内存,那么肯定不会是杂乱无章的,JVM对内存的管理采用的是分区治理,不同的内存区域有着各自的职责所在,在虚拟机运行时,内存区域如下划分: - -![点击查看图片来源](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg2018.cnblogs.com%2Fblog%2F1722965%2F201906%2F1722965-20190623004137470-1024717774.png&refer=http%3A%2F%2Fimg2018.cnblogs.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1646115263&t=2840f72b39c461e22e5a77d0de0e3e1e) - -我们可以看到,内存区域一共分为5个区域,其中方法区和堆是所有线程共享的区域,随着虚拟机的创建而创建,虚拟机的结束而销毁,而虚拟机栈、本地方法栈、程序计数器都是线程之间相互隔离的,每个线程都有一个自己的区域,并且线程启动时会自动创建,结束之后会自动销毁。内存划分完成之后,我们的JVM执行引擎和本地库接口,也就是Java程序开始运行之后就会根据分区合理地使用对应区域的内存了。 - -### 大致划分 - -#### 程序计数器 - -首先我们来介绍一下程序计数器,它和我们的传统8086 CPU中PC寄存器的工作差不多,因为JVM虚拟机目的就是实现物理机那样的程序执行。在8086 CPU中,PC作为程序计数器,负责储存内存地址,该地址指向下一条即将执行的指令,每解释执行完一条指令,PC寄存器的值就会自动被更新为下一条指令的地址,进入下一个指令周期时,就会根据当前地址所指向的指令,进行执行。 - -而JVM中的程序计数器可以看做是当前线程所执行字节码的行号指示器,而行号正好就指的是某一条指令,字节码解释器在工作时也会改变这个值,来指定下一条即将执行的指令。 - -因为Java的多线程也是依靠时间片轮转算法进行的,因此一个CPU同一时间也只会处理一个线程,当某个线程的时间片消耗完成后,会自动切换到下一个线程继续执行,而当前线程的执行位置会被保存到当前线程的程序计数器中,当下次轮转到此线程时,又继续根据之前的执行位置继续向下执行。 - -程序计数器因为只需要记录很少的信息,所以只占用很少一部分内存。 - -#### 虚拟机栈 - -虚拟机栈就是一个非常关键的部分,看名字就知道它是一个栈结构,每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(其实就是栈里面的一个元素),栈帧中包括了当前方法的一些信息,比如局部变量表、操作数栈、动态链接、方法出口等。 - -![image-20220131110349472](https://tva1.sinaimg.cn/large/008i3skNly1gywoc0w7ouj30xm0hy401.jpg) - -其中局部变量表就是我们方法中的局部变量,之前我们也进行过演示,实际上局部变量表在class文件中就已经定义好了,操作数栈就是我们之前字节码执行时使用到的栈结构; 每个栈帧还保存了一个**可以指向当前方法所在类**的运行时常量池,目的是:当前方法中如果需要调用其他方法的时候,能够从运行时常量池中找到对应的符号引用,然后将符号引用转换为直接引用,然后就能直接调用对应方法,这就是动态链接(我们还没讲到常量池,暂时记住即可,建议之后再回顾一下),最后是方法出口,也就是方法该如何结束,是抛出异常还是正常返回。 - -可能听起来有点懵逼,这里我们来模拟一下整个虚拟机栈的运作流程,我们先编写一个测试类: - -```java -public class Main { - public static void main(String[] args) { - int res = a(); - System.out.println(res); - } - - public static int a(){ - return b(); - } - - public static int b(){ - return c(); - } - - public static int c(){ - int a = 10; - int b = 20; - return a + b; - } -} -``` - -当我们的主方法执行后,会依次执行三个方法`a() -> b() -> c() -> 返回`,我们首先来观察一下反编译之后的结果: - -``` -{ - public com.test.Main(); #这个是构造方法 - descriptor: ()V - flags: ACC_PUBLIC - Code: - stack=1, locals=1, args_size=1 - 0: aload_0 - 1: invokespecial #1 // Method java/lang/Object."":()V - 4: return - LineNumberTable: - line 3: 0 - LocalVariableTable: - Start Length Slot Name Signature - 0 5 0 this Lcom/test/Main; - - public static void main(java.lang.String[]); #主方法 - descriptor: ([Ljava/lang/String;)V - flags: ACC_PUBLIC, ACC_STATIC - Code: - stack=2, locals=2, args_size=1 - 0: invokestatic #2 // Method a:()I - 3: istore_1 - 4: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; - 7: iload_1 - 8: invokevirtual #4 // Method java/io/PrintStream.println:(I)V - 11: return - LineNumberTable: - line 5: 0 - line 6: 4 - line 7: 11 - LocalVariableTable: - Start Length Slot Name Signature - 0 12 0 args [Ljava/lang/String; - 4 8 1 res I - - public static int a(); - descriptor: ()I - flags: ACC_PUBLIC, ACC_STATIC - Code: - stack=1, locals=0, args_size=0 - 0: invokestatic #5 // Method b:()I - 3: ireturn - LineNumberTable: - line 10: 0 - - public static int b(); - descriptor: ()I - flags: ACC_PUBLIC, ACC_STATIC - Code: - stack=1, locals=0, args_size=0 - 0: invokestatic #6 // Method c:()I - 3: ireturn - LineNumberTable: - line 14: 0 - - public static int c(); - descriptor: ()I - flags: ACC_PUBLIC, ACC_STATIC - Code: - stack=2, locals=2, args_size=0 - 0: bipush 10 - 2: istore_0 - 3: bipush 20 - 5: istore_1 - 6: iload_0 - 7: iload_1 - 8: iadd - 9: ireturn - LineNumberTable: - line 18: 0 - line 19: 3 - line 20: 6 - LocalVariableTable: - Start Length Slot Name Signature - 3 7 0 a I - 6 4 1 b I -} - -``` - -可以看到在编译之后,我们整个方法的最大操作数栈深度、局部变量表都是已经确定好的,当我们程序开始执行时,会根据这些信息封装为对应的栈帧,我们从`main`方法开始看起: - -![image-20220131142625842](https://tva1.sinaimg.cn/large/008i3skNly1gywucw6rcyj30ws0gyq4h.jpg) - -接着我们继续往下,到了` 0: invokestatic #2 // Method a:()I`时,需要调用方法`a()`,这时当前方法就不会继续向下运行了,而是去执行方法`a()`,那么同样的,将此方法也入栈,注意是放入到栈顶位置,`main`方法的栈帧会被压下去: - -![image-20220131143641690](https://tva1.sinaimg.cn/large/008i3skNly1gywuhfjok5j30v40g875z.jpg) - -这时,进入方法a之后,又继而进入到方法b,最后在进入c,因此,到达方法c的时候,我们的虚拟机栈变成了: - -![image-20220131144209743](https://tva1.sinaimg.cn/large/008i3skNly1gywun3qnp6j30zq0h6jtq.jpg) - -现在我们依次执行方法c中的指令,最后返回a+b的结果,在方法c返回之后,也就代表方法c已经执行结束了,栈帧4会自动出栈,这时栈帧3就得到了上一栈帧返回的结果,并继续执行,但是由于紧接着马上就返回,所以继续重复栈帧4的操作,此时栈帧3也出栈并继续将结果交给下一个栈帧2,最后栈帧2再将结果返回给栈帧1,然后栈帧1就可以继续向下运行了,最后输出结果。 - -![image-20220131144955668](https://tva1.sinaimg.cn/large/008i3skNgy1gywxbv24qlj30tk0giwg2.jpg) - -#### 本地方法栈 - -本地方法栈与虚拟机栈作用差不多,但是它 备的,这里不多做介绍。 - -#### 堆 - -堆是整个Java应用程序共享的区域,也是整个虚拟机最大的一块内存空间,而此区域的职责就是存放和管理对象和数组,而我们马上要提到的垃圾回收机制也是主要作用于这一部分内存区域。 - -#### 方法区 - -方法区也是整个Java应用程序共享的区域,它用于存储所有的类信息、常量、静态变量、动态编译缓存等数据,可以大致分为两个部分,一个是类信息表,一个是运行时常量池。方法区也是我们要重点介绍的部分。 - -![image-20220201140516096](https://tva1.sinaimg.cn/large/008i3skNly1gyxz722qmjj31520mmgo9.jpg) - -首先类信息表中存放的是当前应用程序加载的所有类信息,包括类的版本、字段、方法、接口等信息,同时会将编译时生成的常量池数据全部存放到运行时常量池中。当然,常量也并不是只能从类信息中获取,在程序运行时,也有可能会有新的常量进入到常量池。 - -其实我们的String类正是利用了常量池进行优化,这里我们编写一个测试用例: - -```java -public static void main(String[] args) { - String str1 = new String("abc"); - String str2 = new String("abc"); - - System.out.println(str1 == str2); - System.out.println(str1.equals(str2)); -} -``` - -得到的结果也是显而易见的,由于`str1`和`str2`是单独创建的两个对象,那么这两个对象实际上会在堆中存放,保存在不同的地址: - -![image-20220201141848804](https://tva1.sinaimg.cn/large/008i3skNly1gyy0jttx6mj318g0iswgd.jpg) - -所以当我们使用`==`判断时,得到的结果`false`,而使用`equals`时因为比较的是值,所以得到`true`。现在我们来稍微修改一下: - -```java -public static void main(String[] args) { - String str1 = "abc"; - String str2 = "abc"; - - System.out.println(str1 == str2); - System.out.println(str1.equals(str2)); -} -``` - -现在我们没有使用new的形式,而是直接使用双引号创建,那么这时得到的结果就变成了两个`true`,这是为什么呢?这其实是因为我们直接使用双引号赋值,会先在常量池中查找是否存在相同的字符串,若存在,则将引用直接指向该字符串;若不存在,则在常量池中生成一个字符串,再将引用指向该字符串: - -![image-20220201142710405](https://tva1.sinaimg.cn/large/008i3skNly1gyy0jrivm4j318k0jcq4q.jpg) - -实际上两次调用String类的`intern()`方法,和上面的效果差不多,也是第一次调用会将堆中字符串复制并放入常量池中,第二次通过此方法获取字符串时,会查看常量池中是否包含,如果包含那么会直接返回常量池中字符串的地址: - -```java -public static void main(String[] args) { - //不能直接写"abc",双引号的形式,写了就直接在常量池里面吧abc创好了 - String str1 = new String("ab")+new String("c"); - String str2 = new String("ab")+new String("c"); - - System.out.println(str1.intern() == str2.intern()); - System.out.println(str1.equals(str2)); -} -``` - -![image-20220201145204505](https://tva1.sinaimg.cn/large/008i3skNly1gyy0jx0o6gj31fk0la41e.jpg) - -所以上述结果中得到的依然是两个`true`。在JDK1.7之后,稍微有一些区别,在调用`intern()`方法时,当常量池中没有对应的字符串时,不会再进行复制操作,而是将其直接修改为指向当前字符串堆中的的引用: - -![image-20220201144747139](https://tva1.sinaimg.cn/large/008i3skNly1gyy0jyvnstj31f20k0di6.jpg) - -```java -public static void main(String[] args) { - //不能直接写"abc",双引号的形式,写了就直接在常量池里面吧abc创好了 - String str1 = new String("ab")+new String("c"); - System.out.println(str1.intern() == str1); -} -``` - -```java -public static void main(String[] args) { - String str1 = new String("ab")+new String("c"); - String str2 = new String("ab")+new String("c"); - - System.out.println(str1 == str1.intern()); - System.out.println(str2.intern() == str1); -} -``` - -所以最后我们会发现,`str1.intern()`和`str1`都是同一个对象,结果为`true`。 - -值得注意的是,在JDK7之后,字符串常量池从方法区移动到了堆中。 - -最后我们再来进行一个总结,各个内存区域的用途: - -* (线程独有)程序计数器:保存当前程序的执行位置。 -* (线程独有)虚拟机栈:通过栈帧来维持方法调用顺序,帮助控制程序有序运行。 -* (线程独有)本地方法栈:同上,作用与本地方法。 -* 堆:所有的对象和数组都在这里保存。 -* 方法区:类信息、即时编译器的代码缓存、运行时常量池。 - -当然,这些内存区域划分仅仅是概念上的,具体的实现过程我们后面还会提到。 - -### 爆内存和爆栈 - -实际上,在Java程序运行时,内存容量不可能是无限制的,当我们的对象创建过多或是数组容量过大时,就会导致我们的堆内存不足以存放更多新的对象或是数组,这时就会出现错误,比如: - -```java -public static void main(String[] args) { - int[] a = new int[Integer.MAX_VALUE]; -} -``` - -这里我们申请了一个容量为21亿多的int型数组,显然,如此之大的数组不可能放在我们的堆内存中,所以程序运行时就会这样: - -```java -Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit - at com.test.Main.main(Main.java:5) -``` - -这里得到了一个`OutOfMemoryError`错误,也就是我们常说的内存溢出错误。我们可以通过参数来控制堆内存的最大值和最小值: - -``` --Xms最小值 -Xmx最大值 -``` - -比如我们现在限制堆内存为固定值1M大小,并且在抛出内存溢出异常时保存当前的内存堆转储快照: - -![image-20220201202346882](https://tva1.sinaimg.cn/large/008i3skNly1gyya4xksfzj31cm0u0dk2.jpg) - -注意堆内存不要设置太小,不然连虚拟机都不足以启动,接着我们编写一个一定会导致内存溢出的程序: - -```java -public class Main { - public static void main(String[] args) { - List list = new ArrayList<>(); - while (true){ - list.add(new Test()); //无限创建Test对象并丢进List中 - } - } - - static class Test{ } -} -``` - -在程序运行之后: - -``` -java.lang.OutOfMemoryError: Java heap space -Dumping heap to java_pid35172.hprof ... -Heap dump file created [12895344 bytes in 0.028 secs] -Exception in thread "main" java.lang.OutOfMemoryError: Java heap space - at java.util.Arrays.copyOf(Arrays.java:3210) - at java.util.Arrays.copyOf(Arrays.java:3181) - at java.util.ArrayList.grow(ArrayList.java:267) - at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:241) - at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:233) - at java.util.ArrayList.add(ArrayList.java:464) - at com.test.Main.main(Main.java:10) -``` - -可以看到错误出现原因正是`Java heap space`,也就是堆内存满了,并且根据我们设定的VM参数,堆内存保存了快照信息。我们可以在IDEA内置的Profiler中进行查看: - -![image-20220201203157213](https://tva1.sinaimg.cn/large/008i3skNly1gyyaddef66j31vo0u0jwq.jpg) - -可以很明显地看到,在创建了360146个Test对象之后,堆内存蚌埠住了,于是就抛出了内存溢出错误。 - -我们接着来看栈溢出,我们知道,虚拟机栈会在方法调用时插入栈帧,那么,设想如果出现无限递归的情况呢? - -```java -public class Main { - public static void main(String[] args) { - test(); - } - - public static void test(){ - test(); - } -} -``` - -这很明显是一个永无休止的程序,并且会不断继续向下调用test方法本身,那么按照我们之前的逻辑推导,无限地插入栈帧那么一定会将虚拟机栈塞满,所以,当栈的深度已经不足以继续插入栈帧时,就会这样: - -``` -Exception in thread "main" java.lang.StackOverflowError - at com.test.Main.test(Main.java:12) - at com.test.Main.test(Main.java:12) - at com.test.Main.test(Main.java:12) - at com.test.Main.test(Main.java:12) - at com.test.Main.test(Main.java:12) - at com.test.Main.test(Main.java:12) - ....以下省略很多行 -``` - -这也是我们常说的栈溢出,它和堆溢出比较类似,也是由于容纳不下才导致的,我们可以使用`-Xss`来设定栈容量。 - -### 申请堆外内存 - -除了堆内存可以存放对象数据以外,我们也可以申请堆外内存(直接内存),也就是不受JVM管控的内存区域,这部分区域的内存需要我们自行去申请和释放,实际上本质就是JVM通过C/C++调用`malloc`函数申请的内存,当然得我们自己去释放了。不过虽然是直接内存,不会受到堆内存容量限制,但是依然会受到本机最大内存的限制,所以还是有可能抛出`OutOfMemoryError`异常。 - -这里我们需要提到一个堆外内存操作类:`Unsafe`,就像它的名字一样,虽然Java提供堆外内存的操作类,但是实际上它是不安全的,只有你完全了解底层原理并且能够合理控制堆外内存,才能安全地使用堆外内存。 - -注意这个类不让我们new,也没有直接获取方式(压根就没想让我们用): - -```java -public final class Unsafe { - - private static native void registerNatives(); - static { - registerNatives(); - sun.reflect.Reflection.registerMethodsToFilter(Unsafe.class, "getUnsafe"); - } - - private Unsafe() {} - - private static final Unsafe theUnsafe = new Unsafe(); - - @CallerSensitive - public static Unsafe getUnsafe() { - Class caller = Reflection.getCallerClass(); - if (!VM.isSystemDomainLoader(caller.getClassLoader())) - throw new SecurityException("Unsafe"); //不是JDK的类,不让用。 - return theUnsafe; - } -``` - -所以我们这里就通过反射给他giao出来: - -```java -public static void main(String[] args) throws IllegalAccessException { - Field unsafeField = Unsafe.class.getDeclaredFields()[0]; - unsafeField.setAccessible(true); - Unsafe unsafe = (Unsafe) unsafeField.get(null); - -} -``` - -成功拿到Unsafe类之后,我们就可以开始申请堆外内存了,比如我们现在想要申请一个int大小的内存空间,并在此空间中存放一个int类型的数据: - -```java -public static void main(String[] args) throws IllegalAccessException { - Field unsafeField = Unsafe.class.getDeclaredFields()[0]; - unsafeField.setAccessible(true); - Unsafe unsafe = (Unsafe) unsafeField.get(null); - - //申请4字节大小的内存空间,并得到对应位置的地址 - long address = unsafe.allocateMemory(4); - //在对应的地址上设定int的值 - unsafe.putInt(address, 6666666); - //获取对应地址上的Int型数值 - System.out.println(unsafe.getInt(address)); - //释放申请到的内容 - unsafe.freeMemory(address); - - //由于内存已经释放,这时数据就没了 - System.out.println(unsafe.getInt(address)); -} -``` - -我们可以来看一下`allocateMemory`底层是如何调用的,这是一个native方法,我们来看C++源码: - -```cpp -UNSAFE_ENTRY(jlong, Unsafe_AllocateMemory0(JNIEnv *env, jobject unsafe, jlong size)) { - size_t sz = (size_t)size; - - sz = align_up(sz, HeapWordSize); - void* x = os::malloc(sz, mtOther); //这里调用了os::malloc方法 - - return addr_to_java(x); -} UNSAFE_END -``` - -接着来看: - -```cpp -void* os::malloc(size_t size, MEMFLAGS flags) { - return os::malloc(size, flags, CALLER_PC); -} - -void* os::malloc(size_t size, MEMFLAGS memflags, const NativeCallStack& stack) { - ... - u_char* ptr; - ptr = (u_char*)::malloc(alloc_size); //调用C++标准库函数 malloc(size) - .... - // we do not track guard memory - return MemTracker::record_malloc((address)ptr, size, memflags, stack, level); -} -``` - -所以,我们上面的Java代码转换为C代码,差不多就是这个意思: - -```c -#include -#include - -int main(){ - int * a = malloc(sizeof(int)); - *a = 6666666; - printf("%d\n", *a); - free(a); - printf("%d\n", *a); -} -``` - -所以说,直接内存实际上就是JVM申请的一块额外的内存空间,但是它并不在受管控的几种内存空间中,当然这些内存依然属于是JVM的,由于JVM提供的堆内存会进行垃圾回收等工作,效率不如直接申请和操作内存来得快,一些比较追求极致性能的框架会用到堆外内存来提升运行速度,如nio框架。 - -当然,Unsafe类不仅仅只是这些功能,在其他系列课程中,我们还会讲到它。 - -*** - -## 垃圾回收机制 - -**注意:**此部分为重点内容。 - -我们前面提到,Java会自动管理和释放内存,它不像C/C++那样要求我们手动管理内存,JVM提供了一套全自动的内存管理机制,当一个Java对象不再用到时,JVM会自动将其进行回收并释放内存,那么对象所占内存在什么时候被回收,如何判定对象可以被回收,以及如何去进行回收工作也是JVM需要关注的问题。 - -### 对象存活判定算法 - -首先我们来套讨论第一个问题,也就是:对象在什么情况下可以被判定为不再使用已经可以回收了?这里就需要提到以下几种垃圾回收算法了。 - -![image-20220222084649786](https://tva1.sinaimg.cn/large/e6c9d24egy1gzm008b8j2j21ik0tagpd.jpg) - -#### 引用计数法 - -我们知道,如果我们要经常操作一个对象,那么首先一定会创建一个引用变量: - -```java -//str就是一个引用类型的变量,它持有对后面字符串对象的引用,可以代表后面这个字符串对象本身 -String str = "lbwnb"; - -//str.xxxxx... -``` - -实际上,我们会发现,只要一个对象还有使用价值,我们就会通过它的引用变量来进行操作,那么可否这样判断一个对象是否还需要被使用: - -* 每个对象都包含一个 **引用计数器**,用于存放引用计数(其实就是存放被引用的次数) -* 每当有一个地方引用此对象时,引用计数`+1` -* 当引用失效( 比如离开了局部变量的作用域或是引用被设定为`null`)时,引用计数`-1` -* 当引用计数为`0`时,表示此对象不可能再被使用,因为这时我们已经没有任何方法可以得到此对象的引用了 - -但是这样存在一个问题,如果两个对象相互引用呢? - -```java -public class Main { - public static void main(String[] args) { - Test a = new Test(); - Test b = new Test(); - - a.another = b; - b.another = a; - - //这里直接把a和b赋值为null,这样前面的两个对象我们不可能再得到了 - a = b = null; - } - - private static class Test{ - Test another; - } -} -``` - -按照引用计数算法,那么当出现以上情况时,虽然我们无法在得到此对象的引用了,并且此对象我们也无需再使用,但是由于这两个对象直接存在相互引用的情况,那么引用计数器的值将会永远是`1`,但是实际上此对象已经没有任何用途了。所以引用计数法并不是最好的解决方案。 - -#### 可达性分析算法 - -目前比较主流的编程语言(包括Java),一般都会使用可达性分析算法来判断对象是否存活,它采用了类似于树结构的搜索机制。 - -首先每个对象的引用都有机会成为树的根节点(GC Roots),可以被选定作为根节点条件如下: - -* 位于虚拟机栈的栈帧中的本地变量表中所引用到的对象(其实就是我们方法中的局部变量)同样也包括本地方法栈中JNI引用的对象。 -* 类的静态成员变量引用的对象。 -* 方法区中,常量池里面引用的对象,比如我们之前提到的`String`类型对象。 -* 被添加了锁的对象(比如synchronized关键字) -* 虚拟机内部需要用到的对象。 - -![image-20220222125507229](https://tva1.sinaimg.cn/large/e6c9d24egy1gzm76iz1mzj217s0ggwgc.jpg) - -一旦已经存在的根节点不满足存在的条件时,那么根节点与对象之间的连接将被断开。此时虽然对象1仍存在对其他对象的引用,但是由于其没有任何根节点引用,所以此对象即可被判定为不再使用。比如某个方法中的局部变量引用,在方法执行完成返回之后: - -![image-20220222130350950](https://tva1.sinaimg.cn/large/e6c9d24egy1gzm7ohrh9kj21bg0heacd.jpg) - -这样就能很好地解决我们刚刚提到的循环引用问题,我们再来重现一下出现循环引用的情况: - -![image-20220222130903349](https://tva1.sinaimg.cn/large/e6c9d24egy1gzm7ofteqej215a0a00tk.jpg) - -可以看到,对象1和对象2依然是存在循环引用的,但是只有他们各自的GC Roots断开,那么就会变成下面这样: - -![image-20220222131219350](https://tva1.sinaimg.cn/large/e6c9d24egy1gzm7of7nnlj21740dq75u.jpg) - -所以,我们最后进行一下总结:如果某个对象无法到达任何GC Roots,则证明此对象是不可能再被使用的。 - -#### 最终判定 - -虽然在经历了可达性分析算法之后基本可能判定哪些对象能够被回收,但是并不代表此对象一定会被回收,我们依然可以在最终判定阶段对其进行挽留。 - -还记得我们之前在讲解`Object`类时提到的`finalize()`方法吗? - -```java -/** - * Called by the garbage collector on an object when garbage collection - * determines that there are no more references to the object. - * A subclass overrides the {@code finalize} method to dispose of - * system resources or to perform other cleanup. - * ... - */ -protected void finalize() throws Throwable { } -``` - -此方法正是最终判定方法,如果子类重写了此方法,那么子类对象在被判定为可回收时,会进行二次确认,也就是执行`finalize()`方法,而在此方法中,当前对象是完全有可能重新建立GC Roots的!所以,如果在二次确认后对象不满足可回收的条件,那么此对象不会被回收,巧妙地逃过了垃圾回收的命运。比如下面这个例子: - -```java -public class Main { - private static Test a; - public static void main(String[] args) throws InterruptedException { - a = new Test(); - - //这里直接把a赋值为null,这样前面的对象我们不可能再得到了 - a = null; - - //手动申请执行垃圾回收操作(注意只是申请,并不一定会执行,但是一般情况下都会执行) - System.gc(); - - //等垃圾回收一下() - Thread.sleep(1000); - - //我们来看看a有没有被回收 - System.out.println(a); - } - - private static class Test{ - @Override - protected void finalize() throws Throwable { - System.out.println(this+" 开始了它的救赎之路!"); - a = this; - } - } -} -``` - -注意`finalize()`方法并不是在主线程调用的,而是虚拟机自动建立的一个低优先级的`Finalizer`线程(正是因为优先级比较低,所以前面才需要等待1秒钟)进行处理,我们可以稍微修改一下看看: - -```java -private static class Test{ - @Override - protected void finalize() throws Throwable { - System.out.println(Thread.currentThread()); - a = this; - } -} -``` - -``` -Thread[Finalizer,8,system] -com.test.Main$Test@232204a1 -``` - -同时,同一个对象的`finalize()`方法只会有一次调用机会,也就是说,如果我们连续两次这样操作,那么第二次,对象必定被回收: - -```java -public static void main(String[] args) throws InterruptedException { - a = new Test(); - //这里直接把a赋值为null,这样前面的对象我们不可能再得到了 - a = null; - //手动申请执行垃圾回收操作(注意只是申请,并不一定会执行,但是一般情况下都会执行) - System.gc(); - //等垃圾回收一下 - Thread.sleep(1000); - - System.out.println(a); - //这里直接把a赋值为null,这样前面的对象我们不可能再得到了 - a = null; - //手动申请执行垃圾回收操作(注意只是申请,并不一定会执行,但是一般情况下都会执行) - System.gc(); - //等垃圾回收一下 - Thread.sleep(1000); - - System.out.println(a); -} -``` - -当然,`finalize()`方法也并不是专门防止对象被回收的,我们可以使用它来释放一些程序使用中的资源等。 - -最后,总结成一张图: - -![image-20220222141854678](https://tva1.sinaimg.cn/large/e6c9d24egy1gzm9o931z4j21n40letdm.jpg) - -当然,除了堆中的对象以外,方法区中的数据也是可以被垃圾回收的,但是回收条件比较严格,这里就暂时不谈了。 - -*** - -### 垃圾回收算法 - -前面我们介绍了对象存活判定算法,现在我们已经可以准确地知道堆中的哪些对象可以被回收了,那么,接下来就该考虑如何对对象进行回收了,垃圾收集器会不定期地检查堆中的对象,查看它们是否满足被回收的条件。我们该如何对这些对象进行回收,是一个一个判断是否需要回收吗? - -#### 分代收集机制 - -实际上,如果我们对堆中的每一个对象都依次判断是否需要回收,这样的效率其实是很低的,那么有没有更好地回收机制呢?第一步,我们可以对堆中的对象进行分代管理。 - -比如某些对象,在多次垃圾回收时,都未被判定为可回收对象,我们完全可以将这一部分对象放在一起,并让垃圾收集器减少回收此区域对象的频率,这样就能很好地提高垃圾回收的效率了。 - -因此,Java虚拟机将堆内存划分为**新生代**、**老年代**和**永久代**(其中永久代是HotSpot虚拟机特有的概念,在JDK8之前方法区实际上就是采用的永久代作为实现,而在JDK8之后,方法区由元空间实现,并且使用的是本地内存,容量大小取决于物理机实际大小,之后会详细介绍)这里我们主要讨论的是**新生代**和**老年代**。 - -不同的分代内存回收机制也存在一些不同之处,在HotSpot虚拟机中,新生代被划分为三块,一块较大的Eden空间和两块较小的Survivor空间,默认比例为8:1:1,老年代的GC评率相对较低,永久代一般存放类信息等(其实就是方法区的实现)如图所示: - -![image-20220222151708141](https://tva1.sinaimg.cn/large/e6c9d24egy1gzmbaa6eg9j217a0ggta0.jpg) - -那么它是如何运作的呢? - -首先,所有新创建的对象,在一开始都会进入到新生代的Eden区(如果是大对象会被直接丢进老年代),在进行新生代区域的垃圾回收时,首先会对所有新生代区域的对象进行扫描,并回收那些不再使用对象: - -![image-20220222153104582](https://tva1.sinaimg.cn/large/e6c9d24egy1gzmbyo48r0j21i20cqq4l.jpg) - -接着,在一次垃圾回收之后,Eden区域没有被回收的对象,会进入到Survivor区。在一开始From和To都是空的,而GC之后,所有Eden区域存活的对象都会直接被放入到From区,最后From和To会发生一次交换,也就是说目前存放我们对象的From区,变为To区,而To区变为From区: - -![image-20220222154032674](https://tva1.sinaimg.cn/large/e6c9d24egy1gzmbyn34yfj21gk0d4gn5.jpg) - -接着就是下一次垃圾回收了,操作与上面是一样的,不过这时由于我们From区域中已经存在对象了,所以,在Eden区的存活对象复制到From区之后,所有To区域中的对象会进行年龄判定(每经历一轮GC年龄`+1`,如果对象的年龄大于`默认值为15`,那么会直接进入到老年代,否则移动到From区) - -![image-20220222154828416](https://tva1.sinaimg.cn/large/e6c9d24egy1gzmc6v1nzcj21h60d2q4l.jpg) - -最后像上面一样交换To区和From区,之后不断重复以上步骤。 - -而垃圾收集也分为: - -* Minor GC - 次要垃圾回收,主要进行新生代区域的垃圾收集。 - * 触发条件:新生代的Eden区容量已满时。 -* Major GC - 主要垃圾回收,主要进行老年代的垃圾收集。 -* Full GC - 完全垃圾回收,对整个Java堆内存和方法区进行垃圾回收。 - * 触发条件1:每次晋升到老年代的对象平均大小大于老年代剩余空间 - * 触发条件2:Minor GC后存活的对象超过了老年代剩余空间 - * 触发条件3:永久代内存不足(JDK8之前) - * 触发条件4:手动调用`System.gc()`方法 - -我们可以添加启动参数来查看JVM的GC日志: - -![image-20220222162536616](https://tva1.sinaimg.cn/large/e6c9d24egy1gzmd9jj8djj21m20gktav.jpg) - -```java -public class Main { - public static void main(String[] args) { - Object o = new Object(); - o = null; - System.gc(); - } -} -``` - -``` -[GC (System.gc()) [PSYoungGen: 2621K->528K(76288K)] 2621K->528K(251392K), 0.0006874 secs] [Times: user=0.01 sys=0.01, real=0.00 secs] -[Full GC (System.gc()) [PSYoungGen: 528K->0K(76288K)] [ParOldGen: 0K->332K(175104K)] 528K->332K(251392K), [Metaspace: 3073K->3073K(1056768K)], 0.0022693 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] -Heap - PSYoungGen total 76288K, used 3277K [0x000000076ab00000, 0x0000000770000000, 0x00000007c0000000) - eden space 65536K, 5% used [0x000000076ab00000,0x000000076ae334d8,0x000000076eb00000) - from space 10752K, 0% used [0x000000076eb00000,0x000000076eb00000,0x000000076f580000) - to space 10752K, 0% used [0x000000076f580000,0x000000076f580000,0x0000000770000000) - ParOldGen total 175104K, used 332K [0x00000006c0000000, 0x00000006cab00000, 0x000000076ab00000) - object space 175104K, 0% used [0x00000006c0000000,0x00000006c00532d8,0x00000006cab00000) - Metaspace used 3096K, capacity 4496K, committed 4864K, reserved 1056768K - class space used 333K, capacity 388K, committed 512K, reserved 1048576K - -``` - -现在我们还只能大致看懂GC日志,不过在学习完成本章全部内容后,我们就可以轻松阅读了。 - -#### 空间分配担保 - -我们可以思考一下,有没有这样一种极端情况(正常情况下新生代的回收率是很高的,所以说不用太担心会经常出现这种问题),在一次GC后,新生代Eden区仍然存在大量的对象(因为GC之后存活对象会进入到一个Survivor区,但是很明显这时已经超出Survivor区的容量了,肯定是装不下的)那么现在该怎么办? - -这时就需要用到空间分配担保机制了,可以把Survivor区无法容纳的对象直接送到老年代,让老年代进行分配担保(当然老年代也得装得下才行)在现实生活中,贷款会指定担保人,就是当借款人还不起钱的时候由担保人来还钱。 - -当新生代无法容纳更多的的对象时,可以把新生代中的对象移动到老年代中,这样新生代就腾出了空间来容纳更多的对象。 - -好,那既然新生代装不下就丢给老年代,那么要是老年代也装不下新生代的数据呢?这时,老年代肯定担保人是当不成了,那么这样的话,首先会判断一下之前的每次垃圾回收进入老年代的平均大小是否小于当前老年代的剩余空间,如果小于,那么说明也许可以放得下(不过也仅仅是也许,依然有可能放不下,因为判断的实际上只是平均值,万一这一次突然非常大呢),否则,会先来一次Full GC,进行一次大规模垃圾回收,来尝试腾出空间,再次判断老年代是否有空间存放,要是还是装不下,直接抛出OOM错误,摆烂。 - -最后,我们来总结一下一次Minor GC的整个过程: - -![image-20220222205605690](https://tva1.sinaimg.cn/large/e6c9d24ely1gzml30209wj21u80ren3q.jpg) - -*** - -#### 标记-清除算法 - -前面我们已经了解了整个堆内存实际上是以分代收集机制为主,但是依然没有讲到具体的收集过程,那么,具体的回收过程又是什么样的呢?首先我们来了解一下最古老的`标记-清除`算法。 - -首先标记出所有需要回收的对象,然后再依次回收掉被标记的对象,或是标记出所有不需要回收的对象,只回收未标记的对象。实际上这种算法是非常基础的,并且最易于理解的(这里对象我就以一个方框代替了,当然实际上存放是我们前说到的GC Roots形式) - -![image-20220222165709034](https://tva1.sinaimg.cn/large/e6c9d24egy1gzme6btluwj21e40c0760.jpg) - -虽然此方法非常简单,但是缺点也是非常明显的 ,首先如果内存中存在大量的对象,那么可能就会存在大量的标记,并且大规模进行清除。并且一次标记清除之后,连续的内存空间可能会出现许许多多的空隙,碎片化会导致连续内存空间利用率降低。 - -#### 标记-复制算法 - -既然标记清除算法在面对大量对象时效率低,那么我们可以采用标记-复制算法。它将容量分为同样大小的两块区域, - -标记复制算法,实际上就是将内存区域划分为大小相同的两块区域,每次只使用其中的一块区域,每次垃圾回收结束后,将所有存活的对象全部复制到另一块区域中,并一次性清空当前区域。虽然浪费了一些时间进行复制操作,但是这样能够很好地解决对象大面积回收后空间碎片化严重的问题。 - -![image-20220222210942507](https://tva1.sinaimg.cn/large/e6c9d24ely1gzmlh5aveqj21ti0u079c.jpg) - -这种算法就非常适用于新生代(因为新生代的回收效率极高,一般不会留下太多的对象)的垃圾回收,而我们之前所说的新生代Survivor区其实就是这个思路,包括8:1:1的比例也正是为了对标记复制算法进行优化而采取的。 - -#### 标记-整理算法 - -虽然标记-复制算法能够很好地应对新生代高回收率的场景,但是放到老年代,它就显得很鸡肋了。我们知道,一般长期都回收不到的对象,才有机会进入到老年代,所以老年代一般都是些钉子户,可能一次GC后,仍然存留很多对象。而标记复制算法会在GC后完整复制整个区域内容,并且会折损50%的区域,显然这并不适用于老年代。 - -那么我们能否这样,在标记所有待回收对象之后,不急着去进行回收操作,而是将所有待回收的对象整齐排列在一段内存空间中,而需要回收的对象全部往后丢,这样,前半部分的所有对象都是无需进行回收的,而后半部分直接一次性清除即可。 - -![image-20220222213208681](https://tva1.sinaimg.cn/large/e6c9d24ely1gzmm4g8voxj21vm08ywhj.jpg) - -虽然这样能保证内存空间充分使用,并且也没有标记复制算法那么繁杂,但是缺点也是显而易见的,它的效率比前两者都低。甚至,由于需要修改对象在内存中的位置,此时程序必须要暂停才可以,在极端情况下,可能会导致整个程序发生停顿(被称为“Stop The World”)。 - -所以,我们可以将标记清除算法和标记整理算法混合使用,在内存空间还不是很凌乱的时候,采用标记清除算法其实是没有多大问题的,当内存空间凌乱到一定程度后,我们可以进行一次标记整理算法。 - -*** - -### 垃圾收集器实现 - -聊完了对象存活判定和垃圾回收算法,接着我们就要看看具体有哪些垃圾回收器的实现了。我们可以自由地为新生代和老年代选择更适合它们的收集器。 - -#### Serial收集器 - -这款垃圾收集器也是元老级别的收集器了,在JDK1.3.1之前,是虚拟机新生代区域收集器的唯一选择。这是一款单线程的垃圾收集器,也就是说,当开始进行垃圾回收时,需要暂停所有的线程,直到垃圾收集工作结束。它的新生代收集算法采用的是标记复制算法,老年代采用的是标记整理算法。 - -![image-20220223104605648](https://tva1.sinaimg.cn/large/e6c9d24ely1gzn92k8ooej21ae0bc75m.jpg) - -可以看到,当进入到垃圾回收阶段时,所有的用户线程必须等待GC线程完成工作,就相当于你打一把LOL 40分钟,中途每隔1分钟网络就卡5秒钟,可能这时你正在打团,结果你被物理控制直接在那里站了5秒钟,这确实让人难以接受。 - -虽然缺点很明显,但是优势也是显而易见的: - -1. 设计简单而高效。 -2. 在用户的桌面应用场景中,内存一般不大,可以在较短时间内完成垃圾收集,只要不频繁发生,使用串行回收器是可以接受的。 - -所以,在客户端模式(一般用于一些桌面级图形化界面应用程序)下的新生代中,默认垃圾收集器至今依然是Serial收集器。我们可以在`java -version`中查看默认的客户端模式: - -``` -openjdk version "1.8.0_322" -OpenJDK Runtime Environment (Zulu 8.60.0.21-CA-macos-aarch64) (build 1.8.0_322-b06) -OpenJDK 64-Bit Server VM (Zulu 8.60.0.21-CA-macos-aarch64) (build 25.322-b06, mixed mode) -``` - -我们可以在jvm.cfg文件中切换JRE为Server VM或是Client VM,默认路径为: - -``` -JDK安装目录/jre/lib/jvm.cfg -``` - -比如我们需要将当前模式切换为客户端模式,那么我们可以这样编辑: - -``` --client KNOWN --server IGNORE -``` - -#### ParNew收集器 - -这款垃圾收集器相当于是Serial收集器的多线程版本,它能够支持多线程垃圾收集: - -![image-20220223111344962](https://tva1.sinaimg.cn/large/e6c9d24ely1gzn9vbvb0mj21c20c00uc.jpg) - -除了多线程支持以外,其他内容基本与Serial收集器一致,并且目前某些JVM默认的服务端模式新生代收集器就是使用的ParNew收集器。 - -#### Parallel Scavenge/Parallel Old收集器 - -Parallel Scavenge同样是一款面向新生代的垃圾收集器,同样采用标记复制算法实现,在JDK6时也推出了其老年代收集器Parallel Old,采用标记整理算法实现: - -![image-20220223112108949](https://tva1.sinaimg.cn/large/e6c9d24ely1gzna31mo1qj21cs0ckjt3.jpg) - -与ParNew收集器不同的是,它会自动衡量一个吞吐量,并根据吞吐量来决定每次垃圾回收的时间,这种自适应机制,能够很好地权衡当前机器的性能,根据性能选择最优方案。 - -目前JDK8采用的就是这种 Parallel Scavenge + Parallel Old 的垃圾回收方案。 - -#### CMS收集器 - -在JDK1.5,HotSpot推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS(Concurrent-Mark-Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发(注意这里的并发和之前的并行是有区别的,并发可以理解为同时运行用户线程和GC线程,而并行可以理解为多条GC线程同时工作)收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。 - -它主要采用标记清除算法: - -![image-20220223114019381](https://tva1.sinaimg.cn/large/e6c9d24ely1gznamys2bdj21as0co404.jpg) - -它的垃圾回收分为4个阶段: - -* 初始标记(需要暂停用户线程):这个阶段的主要任务仅仅只是标记出GC Roots能直接关联到的对象,速度比较快,不用担心会停顿太长时间。 -* 并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。 -* 重新标记(需要暂停用户线程):由于并发标记阶段可能某些用户线程会导致标记产生变得,因此这里需要再次暂停所有线程进行并行标记,这个时间会比初始标记时间长一丢丢。 -* 并发清除:最后就可以直接将所有标记好的无用对象进行删除,因为这些对象程序中也用不到了,所以可以与用户线程并发运行。 - -虽然它的优点非常之大,但是缺点也是显而易见的,我们之前说过,标记清除算法会产生大量的内存碎片,导致可用连续空间逐渐变少,长期这样下来,会有更高的概率触发Full GC,并且在与用户线程并发执行的情况下,也会占用一部分的系统资源,导致用户线程的运行速度一定程度上减慢。 - -不过,如果你希望的是最低的GC停顿时间,这款垃圾收集器无疑是最佳选择,不过自从G1收集器问世之后,CMS收集器不再推荐使用了。 - -#### Garbage First (G1) 收集器 - -此垃圾收集器也是一款划时代的垃圾收集器,在JDK7的时候正式走上历史舞台,它是一款主要面向于服务端的垃圾收集器,并且在JDK9时,取代了JDK8默认的 Parallel Scavenge + Parallel Old 的回收方案。 - -我们知道,我们的垃圾回收分为`Minor GC`、`Major GC `和`Full GC`,它们分别对应的是新生代,老年代和整个堆内存的垃圾回收,而G1收集器巧妙地绕过了这些约定,它将整个Java堆划分成`2048`个大小相同的独立`Region`块,每个`Region块`的大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且都为2的N次幂。所有的`Region`大小相同,且在JVM的整个生命周期内不会发生改变。 - -那么分出这些`Region`有什么意义呢?每一个`Region`都可以根据需要,自由决定扮演哪个角色(Eden、Survivor和老年代),收集器会根据对应的角色采用不同的回收策略。此外,G1收集器还存在一个Humongous区域,它专门用于存放大对象(一般认为大小超过了Region容量一半的对象为大对象)这样,新生代、老年代在物理上,不再是一个连续的内存区域,而是到处分布的。 - -![image-20220223123636582](https://tva1.sinaimg.cn/large/e6c9d24ely1gznc9jvdzdj21f40eiq4g.jpg) - -它的回收过程与CMS大体类似: - -![image-20220223123557871](https://tva1.sinaimg.cn/large/e6c9d24ely1gznc8vqqqij21h00emwgt.jpg) - -分为以下四个步骤: - -* 初始标记(暂停用户线程):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。 -* 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。 -* 最终标记(暂停用户线程):对用户线程做一个短暂的暂停,用于处理并发标记阶段漏标的那部分对象。 -* 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多个收集器线程并行完成的。 - -*** - -### 元空间 - -JDK8之前,Hotspot虚拟机的方法区实际上是永久代实现的。在JDK8之后,Hotspot虚拟机不再使用永久代,而是采用了全新的元空间。类的元信息被存储在元空间中。元空间没有使用堆内存,而是与堆不相连的本地内存区域。所以,理论上系统可以使用的内存有多大,元空间就有多大,所以不会出现永久代存在时的内存溢出问题。这项改造也是有必要的,永久代的调优是很困难的,虽然可以设置永久代的大小,但是很难确定一个合适的大小,因为其中的影响因素很多,比如类数量的多少、常量数量的多少等。 - -![image-20220223130536357](https://tva1.sinaimg.cn/large/e6c9d24ely1gznd3pdzvyj21q20fcacr.jpg) - -因此在JDK8时直接将本地内存作为元空间(**Metaspace**)的区域,物理内存有多大,元空间内存就可以有多大,这样永久代的空间分配问题就讲解了,所以最终它变成了这样: - -![image-20220223125137512](https://tva1.sinaimg.cn/large/e6c9d24ely1gzncp6mhikj21ik0migqv.jpg) - -到此,我们对于JVM内存区域的讲解就基本完成了。 - -*** - -### 其他引用类型 - -最后,我们来介绍一下其他引用类型。 - -我们知道,在Java中,如果变量是一个对象类型的,那么它实际上存放的是对象的引用,但是如果是一个基本类型,那么存放的就是基本类型的值。实际上我们平时代码中类似于`Object o = new Object()`这样的的引用类型,细分之后可以称为`强引用`。 - -我们通过前面的学习可以明确,如果方法中存在这样的`强引用`类型,现在需要回收强引用所指向的对象,那么要么此方法运行结束,要么引用连接断开,否则被引用的对象是无法被判定为可回收的,因为我们说不定后面还要使用它。 - -所以,当JVM内存空间不足时,JVM宁愿抛出OutOfMemoryError使程序异常终止,也不会靠随意回收具有强引用的“存活”对象来解决内存不足的问题。 - -除了强引用之外,Java也为我们提供了三种额外的引用类型。 - -#### 软引用 - -软引用不像强引用那样不可回收,当 JVM 认为内存不足时,会去试图回收软引用指向的对象,即JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。当然,如果内存充足,那么是不会轻易被回收的。 - -我们可以通过以下方式来创建一个软引用: - -```java -public class Main { - public static void main(String[] args) { - //强引用写法:Object obj = new Object(); - //软引用写法: - SoftReference reference = new SoftReference<>(new Object()); - //使用get方法就可以获取到软引用所指向的对象了 - System.out.println(reference.get()); - } -} -``` - -可以看到软引用还存在一个带队列的构造方法,软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。 - -这里我们来进行一个测试,首先我们需要设定一下参数,来限制最大堆内存为10M,并且打印GC日志: - -``` --XX:+PrintGCDetails -Xms10M -Xmx10M -``` - -接着运行以下代码: - -```java -public class Main { - public static void main(String[] args) { - ReferenceQueue queue = new ReferenceQueue<>(); - SoftReference reference = new SoftReference<>(new Object(), queue); - System.out.println(reference); - - try{ - List list = new ArrayList<>(); - while (true) list.add(new String("lbwnb")); - }catch (Throwable t){ - System.out.println("发生了内存溢出!"+t.getMessage()); - System.out.println("软引用对象:"+reference.get()); - System.out.println(queue.poll()); - } - } -} -``` - -运行结果如下: - -``` -java.lang.ref.SoftReference@232204a1 -[GC (Allocation Failure) [PSYoungGen: 3943K->501K(4608K)] 3943K->2362K(15872K), 0.0050615 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] -[GC (Allocation Failure) [PSYoungGen: 3714K->496K(4608K)] 5574K->4829K(15872K), 0.0049642 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] -[GC (Allocation Failure) [PSYoungGen: 3318K->512K(4608K)] 7652K->7711K(15872K), 0.0059440 secs] [Times: user=0.03 sys=0.00, real=0.00 secs] -[GC (Allocation Failure) --[PSYoungGen: 4608K->4608K(4608K)] 11807K->15870K(15872K), 0.0078912 secs] [Times: user=0.05 sys=0.00, real=0.01 secs] -[Full GC (Ergonomics) [PSYoungGen: 4608K->0K(4608K)] [ParOldGen: 11262K->10104K(11264K)] 15870K->10104K(15872K), [Metaspace: 3207K->3207K(1056768K)], 0.0587856 secs] [Times: user=0.24 sys=0.00, real=0.06 secs] -[Full GC (Ergonomics) [PSYoungGen: 4096K->1535K(4608K)] [ParOldGen: 10104K->11242K(11264K)] 14200K->12777K(15872K), [Metaspace: 3207K->3207K(1056768K)], 0.0608198 secs] [Times: user=0.25 sys=0.01, real=0.06 secs] -[Full GC (Ergonomics) [PSYoungGen: 3965K->3896K(4608K)] [ParOldGen: 11242K->11242K(11264K)] 15207K->15138K(15872K), [Metaspace: 3207K->3207K(1056768K)], 0.0972088 secs] [Times: user=0.58 sys=0.00, real=0.10 secs] -[Full GC (Allocation Failure) [PSYoungGen: 3896K->3896K(4608K)] [ParOldGen: 11242K->11225K(11264K)] 15138K->15121K(15872K), [Metaspace: 3207K->3207K(1056768K)], 0.1028222 secs] [Times: user=0.63 sys=0.01, real=0.10 secs] -发生了内存溢出!Java heap space -软引用对象:null -java.lang.ref.SoftReference@232204a1 -Heap - PSYoungGen total 4608K, used 4048K [0x00000007bfb00000, 0x00000007c0000000, 0x00000007c0000000) - eden space 4096K, 98% used [0x00000007bfb00000,0x00000007bfef40a8,0x00000007bff00000) - from space 512K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007bff80000) - to space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000) - ParOldGen total 11264K, used 11225K [0x00000007bf000000, 0x00000007bfb00000, 0x00000007bfb00000) - object space 11264K, 99% used [0x00000007bf000000,0x00000007bfaf64a8,0x00000007bfb00000) - Metaspace used 3216K, capacity 4500K, committed 4864K, reserved 1056768K - class space used 352K, capacity 388K, committed 512K, reserved 1048576K -``` - -可以看到,当内存不足时,软引用所指向的对象被回收了,所以`get()`方法得到的结果为null,并且软引用对象本身被丢进了队列中。 - -#### 弱引用 - -弱引用比软引用的生命周期还要短,在进行垃圾回收时,不管当前内存空间是否充足,都会回收它的内存。 - -我们可以像这样创建一个弱引用: - -```java -public class Main { - public static void main(String[] args) { - WeakReference reference = new WeakReference<>(new Object()); - System.out.println(reference.get()); - } -} -``` - -使用方法和软引用是差不多的,但是如果我们在这之前手动进行一次GC: - -```java -public class Main { - public static void main(String[] args) { - SoftReference softReference = new SoftReference<>(new Object()); - WeakReference weakReference = new WeakReference<>(new Object()); - - //手动GC - System.gc(); - - System.out.println("软引用对象:"+softReference.get()); - System.out.println("弱引用对象:"+weakReference.get()); - } -} -``` - -可以看到,弱引用对象直接就被回收了,而软引用对象没有被回收。同样的,它也支持ReferenceQueue,和软引用用法一致,这里就不多做介绍了。 - -`WeakHashMap`正是一种类似于弱引用的HashMap类,如果Map中的Key没有其他引用那么此Map会自动丢弃此键值对。 - -```java -public class Main { - public static void main(String[] args) { - Integer a = new Integer(1); - - WeakHashMap weakHashMap = new WeakHashMap<>(); - weakHashMap.put(a, "yyds"); - System.out.println(weakHashMap); - - a = null; - System.gc(); - - System.out.println(weakHashMap); - } -} -``` - -可以看到,当变量a的引用断开后,这时只有WeakHashMap本身对此对象存在引用,所以在GC之后,这个键值对就自动被舍弃了。所以说这玩意,就挺适合拿去做缓存的。 - -#### 虚引用(鬼引用) - -虚引用相当于没有引用,随时都有可能会被回收。 - -看看它的源码,非常简单: - -```java -public class PhantomReference extends Reference { - - /** - * Returns this reference object's referent. Because the referent of a - * phantom reference is always inaccessible, this method always returns - * null. - * - * @return null - */ - public T get() { - return null; - } - - /** - * Creates a new phantom reference that refers to the given object and - * is registered with the given queue. - * - *

It is possible to create a phantom reference with a null - * queue, but such a reference is completely useless: Its get - * method will always return null and, since it does not have a queue, it - * will never be enqueued. - * - * @param referent the object the new phantom reference will refer to - * @param q the queue with which the reference is to be registered, - * or null if registration is not required - */ - public PhantomReference(T referent, ReferenceQueue q) { - super(referent, q); - } - -} -``` - -也就是说我们无论调用多少次`get()`方法得到的永远都是`null`,因为虚引用本身就不算是个引用,相当于这个对象不存在任何引用,并且只能使用带队列的构造方法,以便对象被回收时接到通知。 - -最后,Java中4种引用的级别由高到低依次为: **强引用 > 软引用 > 弱引用 > 虚引用** \ No newline at end of file diff --git a/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(一)重制版.md b/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(一)重制版.md deleted file mode 100644 index 16094df..0000000 --- a/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(一)重制版.md +++ /dev/null @@ -1,478 +0,0 @@ -![image-20220916142151904](https://s2.loli.net/2022/09/16/XZGlpWmeO7T8ft2.png) - -# 新手入门篇 - -**注意:**开始学习JavaSE之前建议学习的前置课程《C语言程序设计》《数据结构》《操作系统》《计算机组成原理》 - -欢迎各位小伙伴来到JavaSE视频教程,期待与各位小伙伴共度这一旅程!视频中所有的文档、资料,都可以直接在视频下方简介中找到,视频非培训机构出品,纯个人录制,不需要加任何公众号、小程序,直接自取即可。 - -教程开始之前,提醒各位小伙伴: - -* 如果你对某样东西不熟悉,请务必保证跟视频中使用一模一样的环境、一模一样的操作方式去使用,不要自作主张,否则出现某些奇怪的问题又不知道怎么办,就会浪费很多时间。 -* 视频依然是基于Java 8进行讲解,不要自己去安装一个其他的版本,想要了解新版本特性可以在另一个视频里面观看。 -* 在学习过程中,尽可能避免出现中文文件夹,包括后面的环境安装、项目创建,都尽量不要放在中文路径下(因为使用中文常常出现奇奇怪怪的问题)建议使用对应的英文单词代替,或者是用拼音都可以,最好只出现英文字母和数字。 -* 本系列教程使用 IDEA社区版(免费)即可,不需要申请终极版。 - -如果觉得本视频对你有帮助,请一键三连支持一下UP主! - -## 计算机思维导论 - -计算机自1946年问世以来,几乎改变了整个世界。 - -现在我们可以通过电脑来做很多事情,比如我们常常听到的什么人工智能、电子竞技、大数据等等,都和计算机息息相关,包括我们现在的手机、平板等智能设备,也是计算机转变而来的。各位可以看看最顶上的这张图片,如果你在小时候接触过计算机,那么一定对这张图片(照片拍摄于1996年,在美国加利福尼亚州加利福尼亚州的锁诺玛县)印象深刻,这张壁纸作为WindowsXP系统的默认壁纸,曾经展示在千家万户的电脑屏幕上。 - -也许你没有接触过计算机,也许你唯一接触计算机就是用来打游戏,也有可能你曾经捣鼓过计算机,在学习C语言之前,先让我们来了解一下计算机的世界。 - -### 计算机的世界 - -**注意:**如果你已经完成了《C语言程序设计》视频教程的学习,可以直接跳过此部分。 - -计算机虽然名字听着很高级,不过它也是由一个个简单电路组成的。 - -![img](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rd1x1rtgj21dy0cw74r.jpg) - -这是我们在初中就学习过的电路图,不过这种电路太过简单,只能完成一些很基础的的操作,比如点亮小灯泡等。 - -很明显想要实现计算机怎么高级的运算机器,肯定是做不到的,这时我们就需要引入更加强大的数字电路了。 - -> 用数字信号完成对数字量进行[算术运算](https://baike.baidu.com/item/算术运算/3118202)和[逻辑运算](https://baike.baidu.com/item/逻辑运算/7224729)的电路称为数字电路,或数字系统。由于它具有逻辑运算和逻辑处理功能,所以又称数字逻辑电路。现代的数字电路由半导体工艺制成的若干数字集成器件构造而成。逻辑门是数字逻辑电路的[基本单元](https://baike.baidu.com/item/基本单元/5246264)。 - -计算机专业一般会在大一开放《数字电路》这门课程,会对计算机底层的数字电路实现原理进行详细介绍。 - -数字电路引入了逻辑判断,我们来看看简单的数字电路: - -![img](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rdj1916aj21iq0dygm4.jpg) - -数字电路中,用电压的高低来区分出两种信号,低电压表示0,高电压表示1,由于只能通过这种方式表示出两种类型的信号,所以计算机采用的是二进制。 - -> [二进制](https://baike.baidu.com/item/二进制/361457)是计算技术中广泛采用的一种[数制](https://baike.baidu.com/item/数制/217113)。二进制数据是用0和1两个数码来表示的数。它的[基数](https://baike.baidu.com/item/基数/4260)为2,进位规则是“逢二进一”,借位规则是“借一当二”。 -> -> 比如我们一般采用的都是十进制表示,比如9再继续加1的话,就需要进位了,变成10,在二进制中,因为只有0和1,所以当1继续加1时,就需要进位了,就变成10了(注意这不是十,读成一零就行了) - -当然,仅仅有两种信号还不够,我们还需要逻辑门来辅助我们完成更多的计算,最基本的逻辑关系是与、或、非,而逻辑门就有相应的是[与门](https://baike.baidu.com/item/与门)、[或门](https://baike.baidu.com/item/或门)和[非门](https://baike.baidu.com/item/非门),可以用电阻、电容、二极管、三极管等分立原件构成(具体咋构成的咱这里就不说了) - -比如与操作,因为只有两种类型,我们一般将1表示为真,0表示为假,与操作(用&表示)要求两个数参与进来,比如: - -- 1 & 1 = 1 必须两边都是真,结果才为真。 - -- 1 & 0 = 0 两边任意一个或者都不是真,结果为假。 - -或运算(用 | 表示): - -- 1 | 0 = 1 两边只要有一个为真,结果就为真 - -- 0 | 0 = 0 两边同时为假,结果才是假 - -非运算实际上就是取反操作(可以是 ! 表示) - -- !1 = 0 - -- !0 = 1 非运算会将真变成假,假变成真 - -有了这些运算之后,我们的电路不仅仅可以实现计算,也可以实现各种各样的逻辑判断,最终才能发展成我们的计算机。 - -前面我们大概介绍一下计算机的底层操作原理,接着我们来看看计算机的基本组成。 - -![img](https://tva1.sinaimg.cn/large/e6c9d24ely1h2reca0cgoj20x60b40uu.jpg) - -相信各位熟知的计算机都是一个屏幕+一个主机的形式,然后配上我们的键盘鼠标,就可以开始使用了,但是实际上标准的计算机结构并没有这么简单,我们来看看: - -![img](https://tva1.sinaimg.cn/large/e6c9d24ely1h2reersjdmj21k20fqdhl.jpg) - -我们电脑最核心的部件,当属CPU,因为几乎所有的运算都是依靠CPU进行(各种各样的计算电路已经在CPU中安排好了,我们只需要发送对应的指令就可以进行对应的运算),它就像我们人的大脑一样,有了大脑才能进行思考。不过光有大脑还不行,还要有一些其他的部分来辅助工作,比如我们想向电脑里面打字,那么就需要连接一个键盘才能输入,我们想要点击桌面上的图标,那么就需要一个鼠标来操作光标,这些都是输入设备。我们的电脑开机之后显示器上会显示出画面,实际上显示器就是输出设备。 - -当然除了这些内容之外,我们的电脑还需要内存来保存运行时的一些数据,以及外存来保存文件(比如硬盘)等。我们常说的iPhone13 512G,这个512G并不是指的内存,而是指的外存,准确的说是用于存放文件硬盘大小,而真正的内存是我们常说的4G/6G/8G运行内存,内存的速度远高于外存的速度,所以1G内存的价格远超1G硬盘的价格。 - -![img](https://tva1.sinaimg.cn/large/e6c9d24ely1h2riw9u06uj21bs0b4jso.jpg) - -计算机包括五大部件:运算器、控制器、存储器、输入和输出设备。有了这一套完整的硬件环境,我们的电脑才算是有了一个完整的身体。 - -### 操作系统概述 - -**注意:**如果你已经完成了《C语言程序设计》视频教程的学习,可以直接跳过此部分。 - -前面我们了解了一下计算机的大致原理和组成结构,但是光有这一套硬件可不行,如何让这一套硬件按照我们想要的方式运作起来,也是非常重要的,这时我们就需要介绍操作系统了。 - -> 操作系统(operating system,简称OS)是管理[计算机硬件](https://baike.baidu.com/item/计算机硬件/5459592)与[软件](https://baike.baidu.com/item/软件/12053)资源的[计算机程序](https://baike.baidu.com/item/计算机程序/3220205)。操作系统需要处理如管理与[配置](https://baike.baidu.com/item/配置/2394679)[内存](https://baike.baidu.com/item/内存/103614)、决定[系统资源](https://baike.baidu.com/item/系统资源/974435)供需的优先次序、控制[输入设备](https://baike.baidu.com/item/输入设备/10823368)与[输出设备](https://baike.baidu.com/item/输出设备/10823333)、操作网络与管理[文件系统](https://baike.baidu.com/item/文件系统/4827215)等基本事务。操作系统也提供一个让用户与系统[交互](https://baike.baidu.com/item/交互/6964417)操作的界面。 -> -> 一般在计算机专业大二,会开放《操作系统》课程,会详细讲解操作系统的底层运作机制和调度。 - -一般我们电脑上都安装了Windows操作系统(苹果笔记本安装的是MacOS操作系统),现在主流的电脑都已经预装Windows11了: - -![img](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rjnis7lkj21ew0e041a.jpg) - -有了操作系统,我们的电脑才能真正运行起来,我们就可以轻松地通过键盘和鼠标来操作电脑了。 - -不过操作系统最开始并不是图形化界面,它类似于Windows中的命令提示符: - -![img](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rkezow9dj21v60ew40f.jpg) - -![img](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rkjitqqnj21kc08475e.jpg) - -没有什么图标这些概念,只有一个简简单单的黑框让我们进行操作,通过输入命令来进行一些简单的使用,程序的运行结果也会在黑框框(命令行)中打印出来,不过虽然仅仅是一个黑框,但是能运行的程序可是非常非常多的,只需要运行我们编写好的程序,就能完成各种各样复杂的计算任务,并且计算机的计算速度远超我们的人脑。 - -> 中国超级计算机系统天河二号,计算速度达到每秒5.49亿亿次。 - -当然,除了我们常见的Windows和MacOS系统之外,还有我们以后需要经常打交道的Linux操作系统,这种操作系统是开源的,意思是所有的人都可以拿到源代码进行修改,于是就出现了很多发行版: - -![img](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rkm3bo7sj21gk0b4dh3.jpg) - -这些发行版有带图形化界面的,也有不带图形化界面的,不带图形化界面的Linux将是我们以后学习的重点。 - -不同操作系统之间的软件并不是通用的,比如Windows下我们的软件一般是.exe后缀名称,而MacOS下则不是,并且也无法直接运行.exe文件,这是因为不同操作系统的具体实现会存在一些不同,程序编译(我们之后会介绍到)之后的格式也会不同,所以是无法做到软件通用的。 - -正是因为有了操作系统,才能够组织我们计算机的底层硬件(包括CPU、内存、输入输出设备等)进行有序工作,没有操作系统电脑就如同一堆废铁,只有躯壳没有灵魂。 - -### 计算机编程语言 - -**注意:**如果你已经完成了《C语言程序设计》视频教程的学习,可以直接跳过此部分。 - -现在我们大致了解了我们的电脑的运作原理,实际上是一套完整的硬件+一个成形的操作系统共同存在的。接着我们就可以开始了解一下计算机的编程语言了。我们前面介绍的操作系统也是由编程语言写出来的,操作系统本身也算是一个软件。 - -![img](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rl228014j21eq0a2gnf.jpg) - -那么操作系统是如何让底层硬件进行工作的呢?实际上就是通过向CPU发送指令来完成的。 - -> 计算机指令就是指挥机器工作的指示和命令,程序就是一系列按一定顺序排列的指令,执行程序的过程就是计算机的工作过程。指令集,就是CPU中用来计算和控制计算机系统的一套指令的集合,而每一种新型的CPU在设计时就规定了一系列与其他硬件电路相配合的指令系统。而指令集的先进与否,也关系到CPU的性能发挥,它也是CPU性能体现的一个重要标志。 - -我们电脑中的CPU有多种多样的,不同的CPU之间可能也会存在不同的架构,比如现在最常用的是x86架构,还有我们手机平板这样的移动设备使用的arm架构,不同的架构指令集也会有不同。 - -我们知道,计算机底层硬件都是采用的0和1这样的二进制表示,所以指令也是一样的,比如(这里随便写的): - -- 000001 - 代表开机 - -- 000010 - 代表关机 - -- 000011 - 代表进行加法运算 - -当我们通过电路发送给CPU这样的二进制指令,CPU就能够根据我们的指令执行对应的任务,而我们编写的程序保存在硬盘中也是这样的二进制形式,我们只需要将这些指令组织好,按照我们的思路一条一条执行对应的命令,就能够让计算机计算任何我们需要的内容了,这其实就是机器语言。 - -不过随着时代的进步,指令集越来越大,CPU支持的运算类型也越来越多,这样的纯二进制编写实在是太累了,并且越来越多的命令我们根本记不住,于是就有了汇编语言。汇编语言将这些二进制的操作码通过助记符来替换: - -- MOV 传送字或字节。 - -- MOVSX 先符号扩展,再传送。 - -- MOVZX 先零扩展,再传送。 - -- PUSH 把字压入堆栈。 - -把这些原有的二进制命令通过一个单词来代替,这样是不是就好记多了,在程序编写完成后,我们只需要最后将这些单词转换回二进制指令就可以了,这也是早期出现的低级编程语言。 - -![img](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rlmjqp3dj217e09agmz.jpg) - -不过虽然通过这些助记符就能够很轻松地记住命令,但是还是不够方便,因为可能我们的程序需要完成一个很庞大的任务,但是如果还是这样一条一条指令进行编写,是不是太慢了点,有时候可能做一个简单的计算,都需要好几条指令来完成。于是,高级编程语言——C语言,终于诞生了。 - -> C语言诞生于美国的[贝尔实验室](https://baike.baidu.com/item/贝尔实验室/686816),由[丹尼斯·里奇](https://baike.baidu.com/item/丹尼斯·里奇/7267171)(Dennis MacAlistair Ritchie)以肯尼斯·蓝·汤普森(Kenneth Lane Thompson)设计的[B语言](https://baike.baidu.com/item/B语言/1845842)为基础发展而来,在它的主体设计完成后,汤普森和里奇用它完全重写了UNIX操作系统,且随着UNIX操作系统的发展,C语言也得到了不断的完善。 - -高级语言不同于低级语言,低级语言的主要操作对象是指令本身,而高级语言却更加符合我们人脑的认知,更像是通过我们人的思维,去告诉计算机你需要做什么,包括语法也会更加的简单易懂。下面是一段简单的C语言代码: - -```c -int main() { - int a = 10; //定义一个a等于10 - int b = 10; //定义一个b等于10 - int c = a + b; //语义非常明确,c就是a加上b计算出来的结果。 - return 0; -} -``` - -不过现在看不懂没关系,我们后面慢慢学。 - -C语言虽然支持按照我们更容易理解的方式去进行编程,但是最后还是会编译成汇编指令最后变成计算机可以直接执行的指令,不过具体的编译过程,我们不需要再关心了,我们只需要去写就可以了,而对我们代码进行编译的东西,称为编译器。 - -![img](https://tva1.sinaimg.cn/large/e6c9d24egy1h2uzn81nwsj21qk07mdhd.jpg) - -当然,除了C语言之外,还有很多其他的高级语言,比如Java、Python、C#、PHP等等,相比其他编程语言,C算是比较古老的一种了,但是时隔多年直至今日,其他编程语言也依然无法撼动它的王者地位: - -![img](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rlw7fvhmj20xo0b0wf4.jpg) - -可以看到在2021年9月,依然排在编程语言排行榜的**第一名**(Python和Java紧随其后),可见这门语言是多么的不可撼动,很多操作系统、高级编程语言底层实现,几乎都是依靠C语言去编写的(包括Java的底层也是C/C++实现的)所以学习这一门语言,对于理工科尤其是计算机专业极为重要,学好C语言你甚至可以融汇贯通到其他语言,学起来也会轻松很多。 - -### 走进Java语言 - -前面我们介绍了C语言,它实际上就是通过编译,将我们可以看懂的代码,翻译为计算机能够直接执行的指令,这样计算机就可以按照我们想要的方式去进行计算了。当然,除了C语言之外,也有其他的语言,比如近几年也很火的Python,它跟C语言不同,它并不会先进行编译,而是直接交给解释器解释执行: - -```python -print("Hello World!") -``` - -![image-20220916150119407](https://s2.loli.net/2022/09/16/xAe9TspMDtlz8SE.png) - -可见,这种方式也可以让计算机按照我们的想法去进行工作。 - -一般来说,编程语言就分为两大类: - -* **编译型语言:**需要先编译为计算机可以直接执行的命令才可以运行。优点是计算机直接运行,性能高;缺点是与平台密切相关,在一种操作系统上编译的程序,无法在其他非同类操作系统上运行,比如Windows下的exe程序在Mac上就无法运行。 -* **解释型语言:**只需要通过解释器代为执行即可,不需要进行编译。优点是可以跨平台,因为解释是解释器的事情,只需要在各个平台上安装对应的解释器,代码不需要任何修改就可以直接运行;缺点是需要依靠解释器解释执行,效率肯定没直接编译成机器指令运行的快,并且会产生额外的资源占用。 - -![image-20220916151925672](https://s2.loli.net/2022/09/16/phfUjyuXLIbR3gJ.png) - -那么我们来看看我们今天要介绍的主角,Java语言(Java之父:James Gosling,詹姆斯·高斯林) - -> Write Once, Run Anywhere. - -这是Java语言的标语,它的目标很明确:一次编写,到处运行,它旨在打破平台的限制,让Java语言可以运行在任何平台上,并且不需要重新编译,实现跨平台运行。 - -Java自1995年正式推出以来,已经度过了快28个春秋,而基于Java语言,我们的生活中也有了各种各样的应用: - -![image-20220916151604563](https://s2.loli.net/2022/09/16/8SWeCjp6M4ufBk2.png) - -* 诺基亚手机上的很多游戏都是使用Java编写的。 -* 安卓系统中的各种应用程序也是使用Java编写的。 -* 著名沙盒游戏《Minecraft》也有对应的Java版本,得益于Java跨平台特性,无论在什么操作系统上都可以玩到这款游戏。 -* ... - -(有关Java的详细发展历程,可以参考《Java核心技术·卷I》第一章) - -可见,Java实际上早已在我们生活中的各个地方扎根。那么,Java语言是什么样的一个运行机制呢? - -实际上我们的Java程序也是需要进行编译才可以运行的,这一点与C语言是一样的,Java程序编译之后会变成`.class`结尾的二进制文件: - -![image-20220916153102763](https://s2.loli.net/2022/09/16/5z2OWQb3B9AhwSZ.png) - -不过不同的是,这种二进制文件计算机并不能直接运行,而是需要交给JVM(Java虚拟机)执行。 - -![image-20220916152514450](https://s2.loli.net/2022/09/16/6HnkcSIfPdVZEpM.png) - -JVM是个什么东西呢?简单来说,它就像我们前面介绍的解释器一样,我们可以将编译完成的`.class`文件直接交给JVM去运行,而程序中要做的事情,也都是由它来告诉计算机该如何去执行。 - -在不同的操作系统下,都有着对应的JVM实现,我们只需要安装好就可以了,而我们程序员只需要将Java程序编译为`.class`文件就可以直接交给JVM运行,无论是什么操作系统,JVM都采用的同一套标准读取和执行`.class`文件,所以说我们编译之后,在任何平台都可以运行,实现跨平台。 - -由于Java又需要编译同时还需要依靠JVM解释执行,所以说Java**既是编译型语言,也是解释型语言。** - -Java分为很多个版本: - -* **JavaSE:**是我们本教程的主要学习目标,它是标准版的Java,也是整个Java的最核心内容,在开始后续课程之前,这是我们不得不越过的一道坎,这个阶段一定要认真扎实地将Java学好,不然到了后面的高级部分,会很头疼。 -* **JavaME:**微缩版Java,已经基本没人用了。 -* **JavaEE:**企业级Java,比如网站开发,它是JavaSE阶段之后的主要学习方向。 - -从下节课开始,我们就正式地进行Java环境的安装和IDE的使用学习。 - -*** - -## 环境安装与IDE使用 - -前面我们介绍了Java语言,以及其本身的一些性质,这一部分我们就开始进行学习环境安装(这一部分请务必跟着操作,不要自作主张地去操作,一开始就出问题其实是最劝退新手的) - -### JDK下载与安装 - -首先我们来介绍一下JDK和JRE,各位小伙伴一定要能够区分这两者才可以。 - -* **JRE(Java Runtime Environment)**:Java的运行环境,安装了运行环境之后,Java程序才可以运行,一般不做开发,只是需要运行Java程序直接按照JRE即可。 -* **JDK(Java Development Kit)**:包含JRE,并且还附带了大量开发者工具,我们学习Java程序开发就使用JDK即可。 - -它们的关系如下: - -![image-20220916154906732](https://s2.loli.net/2022/09/16/MpGWrh5xZdI3bCJ.png) - -那么现在我们就去下载JDK吧,这里推荐安装免费的ZuluJDK:https://www.azul.com/downloads/?version=java-8-lts&package=jdk - -在这里选择自己的操作系统对应的安装包: - -![image-20220916155142546](https://s2.loli.net/2022/09/16/thaGoKI8pXA7Vl6.png) - -比如Windows下,我们就选择`.msi`的安装包即可(MacOS、Linux下同样选择对应的即可) - -![image-20220916155242814](https://s2.loli.net/2022/09/16/vjc62OFaqmAegCh.png) - -下载完成后,我们直接双击安装: - -![image-20220916160027645](https://s2.loli.net/2022/09/16/Loi3Ru7FAWHP6vN.png) - -**注意,这里不建议各位小伙伴去修改安装的位置!**新手只建议安装到默认位置(不要总担心C盘不够,该装的还是要装,尤其是这种环境,实在装不下就去将其他磁盘的空间分到C盘一部分)如果是因为没有安装到默认位置出现了任何问题,你要是找不到大佬问的话,又得重新来一遍,就很麻烦。 - -剩下的我们只需要一路点击Next即可,安装完成之后,我们打开CMD命令窗口(MacOS下直接打开“终端”)来验证一下(要打开CMD命令窗口,Windows11可以直接在下面的搜索框搜索cmd即可,或者直接在文件资源管理器路径栏输入cmd也可以) - -我们直接输入java命令即可: - -![image-20220916160756046](https://s2.loli.net/2022/09/16/ROD3vkzwT8yFqrc.png) - -如果能够直接输出内容,说明环境已经安装成功了,正常情况下已经配置好了,我们不需要手动去配置什么环境变量,所以说安装好就别管了。 - -输入`java -version`可以查看当前安装的JDK版本: - -![image-20220916161050161](https://s2.loli.net/2022/09/16/cPpCTOf9zZsWSw8.png) - -只要是1.8.0就没问题了,后面的小版本号可能你们会比我的还要新。 - -这样我们就完成了Java环境的安装,我们可以来体验一下编写并且编译运行一个简单的Java程序,我们新建一个文本文档,命名为`Main.txt`(如果没有显示后缀名,需要在文件资源管理器中开启一下)然后用记事本打开,输入以下内容: - -```java -public class Main{ - public static void main(String[] args){ - System.out.println("Hello World!"); - } -} -``` - -现在看不懂没关系,直接用就行,我们后面会一点一点讲解的。 - -编辑好之后,保存退出,接着我们将文件的后缀名称修改为`.java`这是Java源程序文件的后缀名称: - -![image-20220916161607822](https://s2.loli.net/2022/09/16/MAPh4aLSwHuRNlU.png) - -此时我们打开CMD,注意要先进入到对应的路径下,比如我们现在的路径: - -![image-20220916161720722](https://s2.loli.net/2022/09/16/8A4oq7XdeLthpmg.png) - -我们使用`cd`命令先进入到这个目录下: - -![image-20220916161802753](https://s2.loli.net/2022/09/16/HifR7pVSmqbP4Kh.png) - -要编译一个Java程序,我们需要使用`javac`命令来进行: - -![image-20220916161857278](https://s2.loli.net/2022/09/16/IPofZRshyuwgciU.png) - -执行后,可以看到目录下多出来了一个`.class`文件: - -![image-20220916161923814](https://s2.loli.net/2022/09/16/UdEJQL6WvIBFXf1.png) - -这样我们就成功编译了一个Java程序,然后我们就可以将其交给JVM运行了,我们直接使用`java`命令即可: - -![image-20220916162048405](https://s2.loli.net/2022/09/16/esLwPFcOj87MrWo.png) - -注意不要加上后缀名称,直接输入文件名字即可,可以看到打印了一个 Hello World! 字样,我们的第一个Java程序就可以运行了。 - -### IDEA安装与使用 - -前面我们介绍了JDK开发环境的安装以及成功编译运行了我们的第一个Java程序。 - -但是我们发现,如果我们以后都使用记事本来进行Java程序开发的话,是不是效率太低了点?我们还要先编辑,然后要改后缀,还要敲命令来编译,有没有更加方便一点的写代码的工具呢?这里我们要介绍的是:**IntelliJ IDEA**(这里不推荐各位小伙伴使用Eclipse,因为操作上没有IDEA这么友好) - -IDEA准确来说是一个集成开发环境(IDE),它集成了大量的开发工具,编写代码的错误检测、代码提示、一键完成编译运行等,非常方便。 - -下载地址:[IntelliJ IDEA:JetBrains 功能强大、符合人体工程学的 Java IDE](https://www.jetbrains.com.cn/idea/)(如果你之前学习C语言程序设计篇使用过CLion,你会发现界面一模一样,这样就能方便你快速上手) - -![image-20220916162544360](https://s2.loli.net/2022/09/16/UfIQzAXBS7TePm9.png) - -我们直接点击下载即可: - -![image-20220916162830260](https://s2.loli.net/2022/09/16/sifjSGwLxYhHgKR.png) - -这个软件本身是付费的,比较贵,而且最近还涨价了,不过这里我们直接下载面的社区版本就行了(JavaSE学习阶段不需要终极版,但是建议有条件的还是申请一个,因为后面JavaWeb开始就需要终极版了,学生和教师可以直接免费申请一年的使用许可,并且每个学期都可以续一年) - -下载好之后,直接按照即可,这个不强制要求安装到C盘,自己随意,但是注意路径中不要出现中文! - -![image-20220916163329410](https://s2.loli.net/2022/09/16/jd64AxEfmQXWTNl.png) - -这里勾选一下创建桌面快捷方式就行: - -![image-20220916163401880](https://s2.loli.net/2022/09/16/buv9QmapGCENcXn.png) - -安装完成后,我们直接打开就可以了: - -![image-20220916163726690](https://s2.loli.net/2022/09/16/rihpxBbQz9jlZWU.png) - -此时界面是全英文,如果各位小伙伴看得惯,可以直接使用全英文的界面(使用英文界面可以认识更多的专业术语词汇,但是可能看起来没中文那么直观,而且IDEA本身功能就比较多,英语不好的小伙伴就很头疼)这里还是建议英语不好的小伙伴使用中文界面,要使用中文只需要安装中文插件即可: - -![image-20220916164025426](https://s2.loli.net/2022/09/16/tW4UPnpaFsfDB9r.png) - -我们打开Plugins插件这一栏,然后直接在插件市场里面搜索Chinese,可以找到一个中文语言包的插件,我们直接Install安装即可,安装完成后点击重启,现在就是中文页面了: - -![image-20220916164235648](https://s2.loli.net/2022/09/16/UFka83Se97COoJK.png) - -如果各位小伙伴不喜欢黑色主题,也可以修改为白色主题,只需要在自定义中进行修改即可,一共四种主题。 - -如果你之前使用过其他IDE编写代码,这里还支持按键映射(采用其他IDE的快捷键方案)有需要的可以自己修改一下: - -![image-20220916164415447](https://s2.loli.net/2022/09/16/3wbt7QhZmq9EKgY.png) - -接下来,我们来看看如何使用IDEA编写Java程序,IDEA是以项目的形式对一个Java程序进行管理的,所以说我们直接创建一个新的项目,点击新建项目: - -![image-20220916164906998](https://s2.loli.net/2022/09/16/4qvjxmozBaJgOuH.png) - -此时来到创建页面: - -![image-20220916164941663](https://s2.loli.net/2022/09/16/ldzGSmYBkr7uO3c.png) - -* **名称:**你的Java项目的名称,随便起就行,尽量只带英文字母和数字,不要出现特殊字符和中文。 -* **位置:**项目的存放位置,可以自己根据情况修改,同样的,路径中不要出现中文。 -* **语言:**IDEA支持编写其他语言的项目,但是这里我们直接选择Java就行了。 -* **构建系统:**在JavaSE阶段一律选择IntelliJ就行了,Maven我们会在JavaWeb之后进行讲解,Gradle会在安卓开发教程中介绍。 -* **JDK:**就是我们之前安装好的JDK,如果是默认路径安装,这里会自动识别(所以说不要随便去改,不然这些地方就很麻烦) - -当然,如果JDK这里没有自动识别到,那么就手动添加一下: - -![image-20220916165351016](https://s2.loli.net/2022/09/16/fDJKB6M3TlWizoQ.png) - -没问题之后,我们直接创建项目: - -![image-20220916165926205](https://s2.loli.net/2022/09/16/aQDnYVx6cZhlRUv.png) - -进入之后,可以看到已经自动帮助我们创建好了一个`java`源文件,跟我们之前的例子是一样的。要编译运行我们的Java程序,只需要直接点击左边的三角形(启动按钮)即可: - -![image-20220916170119203](https://s2.loli.net/2022/09/16/nyWCev6SNkH9oMm.png) - -点击之后,会在下方自动开始构建: - -![image-20220916170201943](https://s2.loli.net/2022/09/16/3791Nedvu8RQxSc.png) - -完成之后,就可以在控制台看到输出的内容了: - -![image-20220916170231090](https://s2.loli.net/2022/09/16/l8G5MwfLJHq3eQD.png) - -我们可以看到新增加了一个`out`目录,这里面就是刚刚编译好的`.class`文件: - -![image-20220916170358904](https://s2.loli.net/2022/09/16/49ywZ8bEQtYdLBP.png) - -IDEA非常强大,即使是编译之后的二进制文件,也可以反编译回原代码的样子: - -![image-20220916170442922](https://s2.loli.net/2022/09/16/DeaO9P8mLRA2uHb.png) - -如果我们想写一个新的Java项目,可以退出当前项目重新创建: - -![image-20220916170558720](https://s2.loli.net/2022/09/16/sIw3ZcarNuA4TS8.png) - -此时项目列表中就有我们刚刚创建的Java项目了: - -![image-20220916170654353](https://s2.loli.net/2022/09/16/urQkEzWw5JOAGLo.png) - -如果你还想探索IDEA的其他功能,可以点击欢迎页最下方的学习: - -![image-20220916164837599](https://s2.loli.net/2022/09/16/MdGZgaBPyqfeIxX.png) - -会有一个专门的引导教程项目,来教你如何使用各项功能: - -![image-20220916164703111](https://s2.loli.net/2022/09/16/I1PcHasEzyxw8eL.png) - -### IDEA新UI介绍和外观设置 - -IDEA在2022年开启了界面新UI的测试,并将在年底前实装,所以说我们将老UI界面改为新的UI界面进行介绍(如果已经是新UI的样式,那么就不需要像下面一样开启了) - -我们随便进入一个项目,然后双击Shift出现搜索框(这个搜索框很好用,什么都能搜)输入`registry` - -![image-20220916171015360](https://s2.loli.net/2022/09/16/gXNG9fqzHJiWtlU.png) - -找到`ide.experimental.ui`,将其勾选上,然后重启IDEA就变成新的UI样式了(你不说这是IDEA我还以为是VS呢) - -![image-20220916171139281](https://s2.loli.net/2022/09/16/4urncqfwQFG3pCT.png) - -这里介绍一下新UI的各个功能,首先是运行项目,依然是点击左侧三角形: - -![image-20220916171251054](https://s2.loli.net/2022/09/16/MwEkSagiTDZIL3y.png) - -在第一次运行后,会自动生成一个运行配置,我们也可以直接点击右上角的运行: - -![image-20220916171324195](https://s2.loli.net/2022/09/16/gtVmywzIBP5io1X.png) - -效果是一样的,都可以编译运行Java项目。上面一排工具栏被丢到了一个菜单里面: - -![image-20220916171421975](https://s2.loli.net/2022/09/16/UvednOgYZ3MEhBH.png) - -如果各位小伙伴觉得代码字体太小了,可以在设置中进行调整: - -![image-20220916171604891](https://s2.loli.net/2022/09/16/3zbAx94vJ5NihtY.png) - -IDEA的所有通知都可以在通知中查看: - -![image-20220916171736462](https://s2.loli.net/2022/09/16/18aOSWXMhZnwPeq.png) - -我们来看右下角,第一个三角形图标是运行的结果: - -![image-20220916171806992](https://s2.loli.net/2022/09/16/4IdVS8mrxnezkqE.png) - -第二栏是终端(其实就是内嵌的一个CMD命令窗口)可以自由敲命令,默认是位于项目根目录下: - -![image-20220916171854171](https://s2.loli.net/2022/09/16/CN9YwJ4LyxWGOIE.png) - -至此,学习前准备就完成了,从下节课开始,我们将正式进入到Java语言的学习中。 \ No newline at end of file diff --git a/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(七)重制版.md b/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(七)重制版.md deleted file mode 100644 index fdbf279..0000000 --- a/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(七)重制版.md +++ /dev/null @@ -1,1769 +0,0 @@ -![image-20221004132312588](https://s2.loli.net/2022/10/04/aRsN9WoS7BcC3uY.png) - -# 多线程与反射 - -前面我们已经讲解了JavaSE的大部分核心内容,最后一章,我们还将继续学习JavaSE中提供的各种高级特性。这些高级特性对于我们之后的学习,会有着举足轻重的作用。 - -## 多线程 - -**注意:**本章节会涉及到 **操作系统** 相关知识。 - -在了解多线程之前,让我们回顾一下`操作系统`中提到的进程概念: - -![b040eadb-8aa1-4b2a-b587-2c0a6b4efa0b](https://s2.loli.net/2022/10/04/GhrSTfNRsc2jFZM.jpg) - -进程是程序执行的实体,每一个进程都是一个应用程序(比如我们运行QQ、浏览器、LOL、网易云音乐等软件),都有自己的内存空间,CPU一个核心同时只能处理一件事情,当出现多个进程需要同时运行时,CPU一般通过`时间片轮转调度`算法,来实现多个进程的同时运行。 - -![image-20221004132729868](https://s2.loli.net/2022/10/04/hUkGafu7vztB4qR.png) - -在早期的计算机中,进程是拥有资源和独立运行的最小单位,也是程序执行的最小单位。但是,如果我希望两个任务同时进行,就必须运行两个进程,由于每个进程都有一个自己的内存空间,进程之间的通信就变得非常麻烦(比如要共享某些数据)而且执行不同进程会产生上下文切换,非常耗时,那么能否实现在一个进程中就能够执行多个任务呢? - -![image-20221004132700554](https://s2.loli.net/2022/10/04/okgq3HEKGn6jBVw.png) - -后来,线程横空出世,一个进程可以有多个线程,线程是程序执行中一个单一的顺序控制流程,现在线程才是程序执行流的最小单元,各个线程之间共享程序的内存空间(也就是所在进程的内存空间),上下文切换速度也高于进程。 - -在Java中,我们从开始,一直以来编写的都是单线程应用程序(运行`main()`方法的内容),也就是说只能同时执行一个任务(无论你是调用方法、还是进行计算,始终都是依次进行的,也就是同步的),而如果我们希望同时执行多个任务(两个方法**同时**在运行或者是两个计算同时在进行,也就是异步的),就需要用到Java多线程框架。实际上一个Java程序启动后,会创建很多线程,不仅仅只运行一个主线程: - -```java -public static void main(String[] args) { - ThreadMXBean bean = ManagementFactory.getThreadMXBean(); - long[] ids = bean.getAllThreadIds(); - ThreadInfo[] infos = bean.getThreadInfo(ids); - for (ThreadInfo info : infos) { - System.out.println(info.getThreadName()); - } -} -``` - -关于除了main线程默认以外的线程,涉及到JVM相关底层原理,在这里不做讲解,了解就行。 - -### 线程的创建和启动 - -通过创建Thread对象来创建一个新的线程,Thread构造方法中需要传入一个Runnable接口的实现(其实就是编写要在另一个线程执行的内容逻辑)同时Runnable只有一个未实现方法,因此可以直接使用lambda表达式: - -```java -@FunctionalInterface -public interface Runnable { - /** - * When an object implementing interface Runnable is used - * to create a thread, starting the thread causes the object's - * run method to be called in that separately executing - * thread. - *

- * The general contract of the method run is that it may - * take any action whatsoever. - * - * @see java.lang.Thread#run() - */ - public abstract void run(); -} -``` - -创建好后,通过调用`start()`方法来运行此线程: - -```java -public static void main(String[] args) { - Thread t = new Thread(() -> { //直接编写逻辑 - System.out.println("我是另一个线程!"); - }); - t.start(); //调用此方法来开始执行此线程 -} -``` - -可能上面的例子看起来和普通的单线程没两样,那我们先来看看下面这段代码的运行结果: - -```java -public static void main(String[] args) { - Thread t = new Thread(() -> { - System.out.println("我是线程:"+Thread.currentThread().getName()); - System.out.println("我正在计算 0-10000 之间所有数的和..."); - int sum = 0; - for (int i = 0; i <= 10000; i++) { - sum += i; - } - System.out.println("结果:"+sum); - }); - t.start(); - System.out.println("我是主线程!"); -} -``` - -我们发现,这段代码执行输出结果并不是按照从上往下的顺序了,因为他们分别位于两个线程,他们是同时进行的!如果你还是觉得很疑惑,我们接着来看下面的代码运行结果: - -```java -public static void main(String[] args) { - Thread t1 = new Thread(() -> { - for (int i = 0; i < 50; i++) { - System.out.println("我是一号线程:"+i); - } - }); - Thread t2 = new Thread(() -> { - for (int i = 0; i < 50; i++) { - System.out.println("我是二号线程:"+i); - } - }); - t1.start(); - t2.start(); -} -``` - -我们可以看到打印实际上是在交替进行的,也证明了他们是在同时运行! - -**注意**:我们发现还有一个run方法,也能执行线程里面定义的内容,但是run是直接在当前线程执行,并不是创建一个线程执行! - -![image-20221004133119997](https://s2.loli.net/2022/10/04/Srx4H8YyRWqXofc.png) - -实际上,线程和进程差不多,也会等待获取CPU资源,一旦获取到,就开始按顺序执行我们给定的程序,当需要等待外部IO操作(比如Scanner获取输入的文本),就会暂时处于休眠状态,等待通知,或是调用`sleep()`方法来让当前线程休眠一段时间: - -```java -public static void main(String[] args) throws InterruptedException { - System.out.println("l"); - Thread.sleep(1000); //休眠时间,以毫秒为单位,1000ms = 1s - System.out.println("b"); - Thread.sleep(1000); - System.out.println("w"); - Thread.sleep(1000); - System.out.println("nb!"); -} -``` - -我们也可以使用`stop()`方法来强行终止此线程: - -```java -public static void main(String[] args) throws InterruptedException { - Thread t = new Thread(() -> { - Thread me = Thread.currentThread(); //获取当前线程对象 - for (int i = 0; i < 50; i++) { - System.out.println("打印:"+i); - if(i == 20) me.stop(); //此方法会直接终止此线程 - } - }); - t.start(); -} -``` - -虽然`stop()`方法能够终止此线程,但是并不是所推荐的做法,有关线程中断相关问题,我们会在后面继续了解。 - -**思考**:猜猜以下程序输出结果: - -```java -private static int value = 0; - -public static void main(String[] args) throws InterruptedException { - Thread t1 = new Thread(() -> { - for (int i = 0; i < 10000; i++) value++; - System.out.println("线程1完成"); - }); - Thread t2 = new Thread(() -> { - for (int i = 0; i < 10000; i++) value++; - System.out.println("线程2完成"); - }); - t1.start(); - t2.start(); - Thread.sleep(1000); //主线程停止1秒,保证两个线程执行完成 - System.out.println(value); -} -``` - -我们发现,value最后的值并不是我们理想的结果,有关为什么会出现这种问题,在我们学习到线程锁的时候,再来探讨。 - -### 线程的休眠和中断 - -我们前面提到,一个线程处于运行状态下,线程的下一个状态会出现以下情况: - -- 当CPU给予的运行时间结束时,会从运行状态回到就绪(可运行)状态,等待下一次获得CPU资源。 -- 当线程进入休眠 / 阻塞(如等待IO请求) / 手动调用`wait()`方法时,会使得线程处于等待状态,当等待状态结束后会回到就绪状态。 -- 当线程出现异常或错误 / 被`stop()` 方法强行停止 / 所有代码执行结束时,会使得线程的运行终止。 - -而这个部分我们着重了解一下线程的休眠和中断,首先我们来了解一下如何使得线程进如休眠状态: - -```java -public static void main(String[] args) { - Thread t = new Thread(() -> { - try { - System.out.println("l"); - Thread.sleep(1000); //sleep方法是Thread的静态方法,它只作用于当前线程(它知道当前线程是哪个) - System.out.println("b"); //调用sleep后,线程会直接进入到等待状态,直到时间结束 - } catch (InterruptedException e) { - e.printStackTrace(); - } - }); - t.start(); -} -``` - -通过调用`sleep()`方法来将当前线程进入休眠,使得线程处于等待状态一段时间。我们发现,此方法显示声明了会抛出一个InterruptedException异常,那么这个异常在什么时候会发生呢? - -```java -public static void main(String[] args) { - Thread t = new Thread(() -> { - try { - Thread.sleep(10000); //休眠10秒 - } catch (InterruptedException e) { - e.printStackTrace(); - } - }); - t.start(); - try { - Thread.sleep(3000); //休眠3秒,一定比线程t先醒来 - t.interrupt(); //调用t的interrupt方法 - } catch (InterruptedException e) { - e.printStackTrace(); - } -} -``` - -我们发现,每一个Thread对象中,都有一个`interrupt()`方法,调用此方法后,会给指定线程添加一个中断标记以告知线程需要立即停止运行或是进行其他操作,由线程来响应此中断并进行相应的处理,我们前面提到的`stop()`方法是强制终止线程,这样的做法虽然简单粗暴,但是很有可能导致资源不能完全释放,而类似这样的发送通知来告知线程需要中断,让线程自行处理后续,会更加合理一些,也是更加推荐的做法。我们来看看interrupt的用法: - -```java -public static void main(String[] args) { - Thread t = new Thread(() -> { - System.out.println("线程开始运行!"); - while (true){ //无限循环 - if(Thread.currentThread().isInterrupted()){ //判断是否存在中断标志 - break; //响应中断 - } - } - System.out.println("线程被中断了!"); - }); - t.start(); - try { - Thread.sleep(3000); //休眠3秒,一定比线程t先醒来 - t.interrupt(); //调用t的interrupt方法 - } catch (InterruptedException e) { - e.printStackTrace(); - } -} -``` - -通过`isInterrupted()`可以判断线程是否存在中断标志,如果存在,说明外部希望当前线程立即停止,也有可能是给当前线程发送一个其他的信号,如果我们并不是希望收到中断信号就是结束程序,而是通知程序做其他事情,我们可以在收到中断信号后,复位中断标记,然后继续做我们的事情: - -```java -public static void main(String[] args) { - Thread t = new Thread(() -> { - System.out.println("线程开始运行!"); - while (true){ - if(Thread.currentThread().isInterrupted()){ //判断是否存在中断标志 - System.out.println("发现中断信号,复位,继续运行..."); - Thread.interrupted(); //复位中断标记(返回值是当前是否有中断标记,这里不用管) - } - } - }); - t.start(); - try { - Thread.sleep(3000); //休眠3秒,一定比线程t先醒来 - t.interrupt(); //调用t的interrupt方法 - } catch (InterruptedException e) { - e.printStackTrace(); - } -} -``` - -复位中断标记后,会立即清除中断标记。那么,如果现在我们想暂停线程呢?我们希望线程暂时停下,比如等待其他线程执行完成后,再继续运行,那这样的操作怎么实现呢? - -```java -public static void main(String[] args) { - Thread t = new Thread(() -> { - System.out.println("线程开始运行!"); - Thread.currentThread().suspend(); //暂停此线程 - System.out.println("线程继续运行!"); - }); - t.start(); - try { - Thread.sleep(3000); //休眠3秒,一定比线程t先醒来 - t.resume(); //恢复此线程 - } catch (InterruptedException e) { - e.printStackTrace(); - } -} -``` - -虽然这样很方便地控制了线程的暂停状态,但是这两个方法我们发现实际上也是不推荐的做法,它很容易导致死锁!有关为什么被弃用的原因,我们会在线程锁继续探讨。 - -### 线程的优先级 - -实际上,Java程序中的每个线程并不是平均分配CPU时间的,为了使得线程资源分配更加合理,Java采用的是抢占式调度方式,优先级越高的线程,优先使用CPU资源!我们希望CPU花费更多的时间去处理更重要的任务,而不太重要的任务,则可以先让出一部分资源。线程的优先级一般分为以下三种: - -- MIN_PRIORITY 最低优先级 -- MAX_PRIORITY 最高优先级 -- NOM_PRIORITY 常规优先级 - -```java -public static void main(String[] args) { - Thread t = new Thread(() -> { - System.out.println("线程开始运行!"); - }); - t.start(); - t.setPriority(Thread.MIN_PRIORITY); //通过使用setPriority方法来设定优先级 -} -``` - -优先级越高的线程,获得CPU资源的概率会越大,并不是说一定优先级越高的线程越先执行! - -### 线程的礼让和加入 - -我们还可以在当前线程的工作不重要时,将CPU资源让位给其他线程,通过使用`yield()`方法来将当前资源让位给其他同优先级线程: - -```java -public static void main(String[] args) { - Thread t1 = new Thread(() -> { - System.out.println("线程1开始运行!"); - for (int i = 0; i < 50; i++) { - if(i % 5 == 0) { - System.out.println("让位!"); - Thread.yield(); - } - System.out.println("1打印:"+i); - } - System.out.println("线程1结束!"); - }); - Thread t2 = new Thread(() -> { - System.out.println("线程2开始运行!"); - for (int i = 0; i < 50; i++) { - System.out.println("2打印:"+i); - } - }); - t1.start(); - t2.start(); -} -``` - -观察结果,我们发现,在让位之后,尽可能多的在执行线程2的内容。 - -当我们希望一个线程等待另一个线程执行完成后再继续进行,我们可以使用`join()`方法来实现线程的加入: - -```java -public static void main(String[] args) { - Thread t1 = new Thread(() -> { - System.out.println("线程1开始运行!"); - for (int i = 0; i < 50; i++) { - System.out.println("1打印:"+i); - } - System.out.println("线程1结束!"); - }); - Thread t2 = new Thread(() -> { - System.out.println("线程2开始运行!"); - for (int i = 0; i < 50; i++) { - System.out.println("2打印:"+i); - if(i == 10){ - try { - System.out.println("线程1加入到此线程!"); - t1.join(); //在i==10时,让线程1加入,先完成线程1的内容,在继续当前内容 - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - } - }); - t1.start(); - t2.start(); -} -``` - -我们发现,线程1加入后,线程2等待线程1待执行的内容全部执行完成之后,再继续执行的线程2内容。注意,线程的加入只是等待另一个线程的完成,并不是将另一个线程和当前线程合并!我们来看看: - -```java -public static void main(String[] args) { - Thread t1 = new Thread(() -> { - System.out.println(Thread.currentThread().getName()+"开始运行!"); - for (int i = 0; i < 50; i++) { - System.out.println(Thread.currentThread().getName()+"打印:"+i); - } - System.out.println("线程1结束!"); - }); - Thread t2 = new Thread(() -> { - System.out.println("线程2开始运行!"); - for (int i = 0; i < 50; i++) { - System.out.println("2打印:"+i); - if(i == 10){ - try { - System.out.println("线程1加入到此线程!"); - t1.join(); //在i==10时,让线程1加入,先完成线程1的内容,在继续当前内容 - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - } - }); - t1.start(); - t2.start(); -} -``` - -实际上,t2线程只是暂时处于等待状态,当t1执行结束时,t2才开始继续执行,只是在效果上看起来好像是两个线程合并为一个线程在执行而已。 - -### 线程锁和线程同步 - -在开始讲解线程同步之前,我们需要先了解一下多线程情况下Java的内存管理: - -![image-20221004203914215](https://s2.loli.net/2022/10/04/ZvI8neF3tdGJwS4.png) - -线程之间的共享变量(比如之前悬念中的value变量)存储在主内存(main memory)中,每个线程都有一个私有的工作内存(本地内存),工作内存中存储了该线程以读/写共享变量的副本。它类似于我们在`计算机组成原理`中学习的多核心处理器高速缓存机制: - -![image-20221004204209038](https://s2.loli.net/2022/10/04/SKlbIZyvxMnauLJ.png) - -高速缓存通过保存内存中数据的副本来提供更加快速的数据访问,但是如果多个处理器的运算任务都涉及同一块内存区域,就可能导致各自的高速缓存数据不一致,在写回主内存时就会发生冲突,这就是引入高速缓存引发的新问题,称之为:缓存一致性。 - -实际上,Java的内存模型也是这样类似设计的,当我们同时去操作一个共享变量时,如果仅仅是读取还好,但是如果同时写入内容,就会出现问题!好比说一个银行,如果我和我的朋友同时在银行取我账户里面的钱,难道取1000还可能吐2000出来吗?我们需要一种更加安全的机制来维持秩序,保证数据的安全性! - -比如我们可以来看看下面这个问题: - -```java -private static int value = 0; - -public static void main(String[] args) throws InterruptedException { - Thread t1 = new Thread(() -> { - for (int i = 0; i < 10000; i++) value++; - System.out.println("线程1完成"); - }); - Thread t2 = new Thread(() -> { - for (int i = 0; i < 10000; i++) value++; - System.out.println("线程2完成"); - }); - t1.start(); - t2.start(); - Thread.sleep(1000); //主线程停止1秒,保证两个线程执行完成 - System.out.println(value); -} -``` - -实际上,当两个线程同时读取value的时候,可能会同时拿到同样的值,而进行自增操作之后,也是同样的值,再写回主内存后,本来应该进行2次自增操作,实际上只执行了一次! - -![image-20221004204439553](https://s2.loli.net/2022/10/04/T2l3xfIP17Gr5dw.png) - -通过synchronized关键字来创造一个线程锁,首先我们来认识一下synchronized代码块,它需要在括号中填入一个内容,必须是一个对象或是一个类,我们在value自增操作外套上同步代码块: - -```java -private static int value = 0; - -public static void main(String[] args) throws InterruptedException { - Thread t1 = new Thread(() -> { - for (int i = 0; i < 10000; i++) { - synchronized (Main.class){ //使用synchronized关键字创建同步代码块 - value++; - } - } - System.out.println("线程1完成"); - }); - Thread t2 = new Thread(() -> { - for (int i = 0; i < 10000; i++) { - synchronized (Main.class){ - value++; - } - } - System.out.println("线程2完成"); - }); - t1.start(); - t2.start(); - Thread.sleep(1000); //主线程停止1秒,保证两个线程执行完成 - System.out.println(value); -} -``` - -我们发现,现在得到的结果就是我们想要的内容了,因为在同步代码块执行过程中,拿到了我们传入对象或类的锁(传入的如果是对象,就是对象锁,不同的对象代表不同的对象锁,如果是类,就是类锁,类锁只有一个,实际上类锁也是对象锁,是Class类实例,但是Class类实例同样的类无论怎么获取都是同一个),但是注意两个线程必须使用同一把锁! - -当一个线程进入到同步代码块时,会获取到当前的锁,而这时如果其他使用同样的锁的同步代码块也想执行内容,就必须等待当前同步代码块的内容执行完毕,在执行完毕后会自动释放这把锁,而其他的线程才能拿到这把锁并开始执行同步代码块里面的内容(实际上synchronized是一种悲观锁,随时都认为有其他线程在对数据进行修改,后面在JUC篇视频教程中我们还会讲到乐观锁,如CAS算法) - -那么我们来看看,如果使用的是不同对象的锁,那么还能顺利进行吗? - -```java -private static int value = 0; - -public static void main(String[] args) throws InterruptedException { - Main main1 = new Main(); - Main main2 = new Main(); - Thread t1 = new Thread(() -> { - for (int i = 0; i < 10000; i++) { - synchronized (main1){ - value++; - } - } - System.out.println("线程1完成"); - }); - Thread t2 = new Thread(() -> { - for (int i = 0; i < 10000; i++) { - synchronized (main2){ - value++; - } - } - System.out.println("线程2完成"); - }); - t1.start(); - t2.start(); - Thread.sleep(1000); //主线程停止1秒,保证两个线程执行完成 - System.out.println(value); -} -``` - -当对象不同时,获取到的是不同的锁,因此并不能保证自增操作的原子性,最后也得不到我们想要的结果。 - -synchronized关键字也可以作用于方法上,调用此方法时也会获取锁: - -```java -private static int value = 0; - -private static synchronized void add(){ - value++; -} - -public static void main(String[] args) throws InterruptedException { - Thread t1 = new Thread(() -> { - for (int i = 0; i < 10000; i++) add(); - System.out.println("线程1完成"); - }); - Thread t2 = new Thread(() -> { - for (int i = 0; i < 10000; i++) add(); - System.out.println("线程2完成"); - }); - t1.start(); - t2.start(); - Thread.sleep(1000); //主线程停止1秒,保证两个线程执行完成 - System.out.println(value); -} -``` - -我们发现实际上效果是相同的,只不过这个锁不用你去给,如果是静态方法,就是使用的类锁,而如果是普通成员方法,就是使用的对象锁。通过灵活的使用synchronized就能很好地解决我们之前提到的问题了。 - -### 死锁 - -其实死锁的概念在`操作系统`中也有提及,它是指两个线程相互持有对方需要的锁,但是又迟迟不释放,导致程序卡住: - -![image-20221004205058223](https://s2.loli.net/2022/10/04/Ja6TPO23wCI8pvn.png) - -我们发现,线程A和线程B都需要对方的锁,但是又被对方牢牢把握,由于线程被无限期地阻塞,因此程序不可能正常终止。我们来看看以下这段代码会得到什么结果: - -```java -public static void main(String[] args) throws InterruptedException { - Object o1 = new Object(); - Object o2 = new Object(); - Thread t1 = new Thread(() -> { - synchronized (o1){ - try { - Thread.sleep(1000); - synchronized (o2){ - System.out.println("线程1"); - } - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - }); - Thread t2 = new Thread(() -> { - synchronized (o2){ - try { - Thread.sleep(1000); - synchronized (o1){ - System.out.println("线程2"); - } - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - }); - t1.start(); - t2.start(); -} -``` - -所以,我们在编写程序时,一定要注意,不要出现这种死锁的情况。那么我们如何去检测死锁呢?我们可以利用jstack命令来检测死锁,首先利用jps找到我们的java进程: - -```shell -nagocoler@NagodeMacBook-Pro ~ % jps -51592 Launcher -51690 Jps -14955 -51693 Main -nagocoler@NagodeMacBook-Pro ~ % jstack 51693 -... -Java stack information for the threads listed above: -=================================================== -"Thread-1": - at com.test.Main.lambda$main$1(Main.java:46) - - waiting to lock <0x000000076ad27fc0> (a java.lang.Object) - - locked <0x000000076ad27fd0> (a java.lang.Object) - at com.test.Main$$Lambda$2/1867750575.run(Unknown Source) - at java.lang.Thread.run(Thread.java:748) -"Thread-0": - at com.test.Main.lambda$main$0(Main.java:34) - - waiting to lock <0x000000076ad27fd0> (a java.lang.Object) - - locked <0x000000076ad27fc0> (a java.lang.Object) - at com.test.Main$$Lambda$1/396873410.run(Unknown Source) - at java.lang.Thread.run(Thread.java:748) - -Found 1 deadlock. -``` - -jstack自动帮助我们找到了一个死锁,并打印出了相关线程的栈追踪信息,同样的,使用`jconsole`也可以进行监测。 - -因此,前面说不推荐使用 `suspend()`去挂起线程的原因,是因为`suspend()`在使线程暂停的同时,并不会去释放任何锁资源。其他线程都无法访问被它占用的锁。直到对应的线程执行`resume()`方法后,被挂起的线程才能继续,从而其它被阻塞在这个锁的线程才可以继续执行。但是,如果`resume()`操作出现在`suspend()`之前执行,那么线程将一直处于挂起状态,同时一直占用锁,这就产生了死锁。 - -### wait和notify方法 - -其实我们之前可能就发现了,Object类还有三个方法我们从来没有使用过,分别是`wait()`、`notify()`以及`notifyAll()`,他们其实是需要配合synchronized来使用的(实际上锁就是依附于对象存在的,每个对象都应该有针对于锁的一些操作,所以说就这样设计了)当然,只有在同步代码块中才能使用这些方法,正常情况下会报错,我们来看看他们的作用是什么: - -```java -public static void main(String[] args) throws InterruptedException { - Object o1 = new Object(); - Thread t1 = new Thread(() -> { - synchronized (o1){ - try { - System.out.println("开始等待"); - o1.wait(); //进入等待状态并释放锁 - System.out.println("等待结束!"); - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - }); - Thread t2 = new Thread(() -> { - synchronized (o1){ - System.out.println("开始唤醒!"); - o1.notify(); //唤醒处于等待状态的线程 - for (int i = 0; i < 50; i++) { - System.out.println(i); - } - //唤醒后依然需要等待这里的锁释放之前等待的线程才能继续 - } - }); - t1.start(); - Thread.sleep(1000); - t2.start(); -} -``` - -我们可以发现,对象的`wait()`方法会暂时使得此线程进入等待状态,同时会释放当前代码块持有的锁,这时其他线程可以获取到此对象的锁,当其他线程调用对象的`notify()`方法后,会唤醒刚才变成等待状态的线程(这时并没有立即释放锁)。注意,必须是在持有锁(同步代码块内部)的情况下使用,否则会抛出异常! - -notifyAll其实和notify一样,也是用于唤醒,但是前者是唤醒所有调用`wait()`后处于等待的线程,而后者是看运气随机选择一个。 - -### ThreadLocal的使用 - -既然每个线程都有一个自己的工作内存,那么能否只在自己的工作内存中创建变量仅供线程自己使用呢? - -![img](https://img2018.cnblogs.com/blog/1368768/201906/1368768-20190613220434628-1803630402.png) - -我们可以使用ThreadLocal类,来创建工作内存中的变量,它将我们的变量值存储在内部(只能存储一个变量),不同的线程访问到ThreadLocal对象时,都只能获取到当前线程所属的变量。 - -```java -public static void main(String[] args) throws InterruptedException { - ThreadLocal local = new ThreadLocal<>(); //注意这是一个泛型类,存储类型为我们要存放的变量类型 - Thread t1 = new Thread(() -> { - local.set("lbwnb"); //将变量的值给予ThreadLocal - System.out.println("变量值已设定!"); - System.out.println(local.get()); //尝试获取ThreadLocal中存放的变量 - }); - Thread t2 = new Thread(() -> { - System.out.println(local.get()); //尝试获取ThreadLocal中存放的变量 - }); - t1.start(); - Thread.sleep(3000); //间隔三秒 - t2.start(); -} -``` - -上面的例子中,我们开启两个线程分别去访问ThreadLocal对象,我们发现,第一个线程存放的内容,第一个线程可以获取,但是第二个线程无法获取,我们再来看看第一个线程存入后,第二个线程也存放,是否会覆盖第一个线程存放的内容: - -```java -public static void main(String[] args) throws InterruptedException { - ThreadLocal local = new ThreadLocal<>(); //注意这是一个泛型类,存储类型为我们要存放的变量类型 - Thread t1 = new Thread(() -> { - local.set("lbwnb"); //将变量的值给予ThreadLocal - System.out.println("线程1变量值已设定!"); - try { - Thread.sleep(2000); //间隔2秒 - } catch (InterruptedException e) { - e.printStackTrace(); - } - System.out.println("线程1读取变量值:"); - System.out.println(local.get()); //尝试获取ThreadLocal中存放的变量 - }); - Thread t2 = new Thread(() -> { - local.set("yyds"); //将变量的值给予ThreadLocal - System.out.println("线程2变量值已设定!"); - }); - t1.start(); - Thread.sleep(1000); //间隔1秒 - t2.start(); -} -``` - -我们发现,即使线程2重新设定了值,也没有影响到线程1存放的值,所以说,不同线程向ThreadLocal存放数据,只会存放在线程自己的工作空间中,而不会直接存放到主内存中,因此各个线程直接存放的内容互不干扰。 - -我们发现在线程中创建的子线程,无法获得父线程工作内存中的变量: - -```java -public static void main(String[] args) { - ThreadLocal local = new ThreadLocal<>(); - Thread t = new Thread(() -> { - local.set("lbwnb"); - new Thread(() -> { - System.out.println(local.get()); - }).start(); - }); - t.start(); -} -``` - -我们可以使用InheritableThreadLocal来解决: - -```java -public static void main(String[] args) { - ThreadLocal local = new InheritableThreadLocal<>(); - Thread t = new Thread(() -> { - local.set("lbwnb"); - new Thread(() -> { - System.out.println(local.get()); - }).start(); - }); - t.start(); -} -``` - -在InheritableThreadLocal存放的内容,会自动向子线程传递。 - -### 定时器 - -我们有时候会有这样的需求,我希望定时执行任务,比如3秒后执行,其实我们可以通过使用`Thread.sleep()`来实现: - -```java -public static void main(String[] args) { - new TimerTask(() -> System.out.println("我是定时任务!"), 3000).start(); //创建并启动此定时任务 -} - -static class TimerTask{ - Runnable task; - long time; - - public TimerTask(Runnable runnable, long time){ - this.task = runnable; - this.time = time; - } - - public void start(){ - new Thread(() -> { - try { - Thread.sleep(time); - task.run(); //休眠后再运行 - } catch (InterruptedException e) { - e.printStackTrace(); - } - }).start(); - } -} -``` - -我们通过自行封装一个TimerTask类,并在启动时,先休眠3秒钟,再执行我们传入的内容。那么现在我们希望,能否循环执行一个任务呢?比如我希望每隔1秒钟执行一次代码,这样该怎么做呢? - -```java -public static void main(String[] args) { - new TimerLoopTask(() -> System.out.println("我是定时任务!"), 3000).start(); //创建并启动此定时任务 -} - -static class TimerLoopTask{ - Runnable task; - long loopTime; - - public TimerLoopTask(Runnable runnable, long loopTime){ - this.task = runnable; - this.loopTime = loopTime; - } - - public void start(){ - new Thread(() -> { - try { - while (true){ //无限循环执行 - Thread.sleep(loopTime); - task.run(); //休眠后再运行 - } - } catch (InterruptedException e) { - e.printStackTrace(); - } - }).start(); - } -} -``` - -现在我们将单次执行放入到一个无限循环中,这样就能一直执行了,并且按照我们的间隔时间进行。 - -但是终究是我们自己实现,可能很多方面还没考虑到,Java也为我们提供了一套自己的框架用于处理定时任务: - -```java -public static void main(String[] args) { - Timer timer = new Timer(); //创建定时器对象 - timer.schedule(new TimerTask() { //注意这个是一个抽象类,不是接口,无法使用lambda表达式简化,只能使用匿名内部类 - @Override - public void run() { - System.out.println(Thread.currentThread().getName()); //打印当前线程名称 - } - }, 1000); //执行一个延时任务 -} -``` - -我们可以通过创建一个Timer类来让它进行定时任务调度,我们可以通过此对象来创建任意类型的定时任务,包延时任务、循环定时任务等。我们发现,虽然任务执行完成了,但是我们的程序并没有停止,这是因为Timer内存维护了一个任务队列和一个工作线程: - -```java -public class Timer { - /** - * The timer task queue. This data structure is shared with the timer - * thread. The timer produces tasks, via its various schedule calls, - * and the timer thread consumes, executing timer tasks as appropriate, - * and removing them from the queue when they're obsolete. - */ - private final TaskQueue queue = new TaskQueue(); - - /** - * The timer thread. - */ - private final TimerThread thread = new TimerThread(queue); - - ... -} -``` - -TimerThread继承自Thread,是一个新创建的线程,在构造时自动启动: - -```java -public Timer(String name) { - thread.setName(name); - thread.start(); -} -``` - -而它的run方法会循环地读取队列中是否还有任务,如果有任务依次执行,没有的话就暂时处于休眠状态: - -```java -public void run() { - try { - mainLoop(); - } finally { - // Someone killed this Thread, behave as if Timer cancelled - synchronized(queue) { - newTasksMayBeScheduled = false; - queue.clear(); // Eliminate obsolete references - } - } -} - -/** - * The main timer loop. (See class comment.) - */ -private void mainLoop() { - try { - TimerTask task; - boolean taskFired; - synchronized(queue) { - // Wait for queue to become non-empty - while (queue.isEmpty() && newTasksMayBeScheduled) //当队列为空同时没有被关闭时,会调用wait()方法暂时处于等待状态,当有新的任务时,会被唤醒。 - queue.wait(); - if (queue.isEmpty()) - break; //当被唤醒后都没有任务时,就会结束循环,也就是结束工作线程 - ... -} -``` - -`newTasksMayBeScheduled`实际上就是标记当前定时器是否关闭,当它为false时,表示已经不会再有新的任务到来,也就是关闭,我们可以通过调用`cancel()`方法来关闭它的工作线程: - -```java -public void cancel() { - synchronized(queue) { - thread.newTasksMayBeScheduled = false; - queue.clear(); - queue.notify(); //唤醒wait使得工作线程结束 - } -} -``` - -因此,我们可以在使用完成后,调用Timer的`cancel()`方法以正常退出我们的程序: - -```java -public static void main(String[] args) { - Timer timer = new Timer(); - timer.schedule(new TimerTask() { - @Override - public void run() { - System.out.println(Thread.currentThread().getName()); - timer.cancel(); //结束 - } - }, 1000); -} -``` - -### 守护线程 - -不要把操作系统重的守护进程和守护线程相提并论! - -守护进程在后台运行运行,不需要和用户交互,本质和普通进程类似。而守护线程就不一样了,当其他所有的非守护线程结束之后,守护线程自动结束,也就是说,Java中所有的线程都执行完毕后,守护线程自动结束,因此守护线程不适合进行IO操作,只适合打打杂: - -```java -public static void main(String[] args) throws InterruptedException{ - Thread t = new Thread(() -> { - while (true){ - try { - System.out.println("程序正常运行中..."); - Thread.sleep(1000); - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - }); - t.setDaemon(true); //设置为守护线程(必须在开始之前,中途是不允许转换的) - t.start(); - for (int i = 0; i < 5; i++) { - Thread.sleep(1000); - } -} -``` - -在守护线程中产生的新线程也是守护的: - -```java -public static void main(String[] args) throws InterruptedException{ - Thread t = new Thread(() -> { - Thread it = new Thread(() -> { - while (true){ - try { - System.out.println("程序正常运行中..."); - Thread.sleep(1000); - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - }); - it.start(); - }); - t.setDaemon(true); //设置为守护线程(必须在开始之前,中途是不允许转换的) - t.start(); - for (int i = 0; i < 5; i++) { - Thread.sleep(1000); - } -} -``` - -### 再谈集合类 - -集合类中有一个东西是Java8新增的Spliterator接口,翻译过来就是:可拆分迭代器(Splitable Iterator)和Iterator一样,Spliterator也用于遍历数据源中的元素,但它是为了并行执行而设计的。Java 8已经为集合框架中包含的所有数据结构提供了一个默认的Spliterator实现。在集合跟接口Collection中提供了一个`spliterator()`方法用于获取可拆分迭代器。 - -其实我们之前在讲解集合类的根接口时,就发现有这样一个方法: - -```java -default Stream parallelStream() { - return StreamSupport.stream(spliterator(), true); //parallelStream就是利用了可拆分迭代器进行多线程操作 -} -``` - -并行流,其实就是一个多线程执行的流,它通过默认的ForkJoinPool实现(这里不讲解原理),它可以提高你的多线程任务的速度。 - -```java -public static void main(String[] args) { - List list = new ArrayList<>(Arrays.asList(1, 4, 5, 2, 9, 3, 6, 0)); - list - .parallelStream() //获得并行流 - .forEach(i -> System.out.println(Thread.currentThread().getName()+" -> "+i)); -} -``` - -我们发现,forEach操作的顺序,并不是我们实际List中的顺序,同时每次打印也是不同的线程在执行!我们可以通过调用`forEachOrdered()`方法来使用单线程维持原本的顺序: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(Arrays.asList(1, 4, 5, 2, 9, 3, 6, 0)); - list - .parallelStream() //获得并行流 - .forEachOrdered(System.out::println); -} -``` - -我们之前还发现,在Arrays数组工具类中,也包含大量的并行方法: - -```java -public static void main(String[] args) { - int[] arr = new int[]{1, 4, 5, 2, 9, 3, 6, 0}; - Arrays.parallelSort(arr); //使用多线程进行并行排序,效率更高 - System.out.println(Arrays.toString(arr)); -} -``` - -更多地使用并行方法,可以更加充分地发挥现代计算机多核心的优势,但是同时需要注意多线程产生的异步问题! - -```java -public static void main(String[] args) { - int[] arr = new int[]{1, 4, 5, 2, 9, 3, 6, 0}; - Arrays.parallelSetAll(arr, i -> { - System.out.println(Thread.currentThread().getName()); - return arr[i]; - }); - System.out.println(Arrays.toString(arr)); -} -``` - -因为多线程的加入,我们之前认识的集合类都废掉了: - -```java -public static void main(String[] args) throws InterruptedException { - List list = new ArrayList<>(); - new Thread(() -> { - for (int i = 0; i < 1000; i++) { - list.add(i); //两个线程同时操作集合类进行插入操作 - } - }).start(); - new Thread(() -> { - for (int i = 1000; i < 2000; i++) { - list.add(i); - } - }).start(); - Thread.sleep(2000); - System.out.println(list.size()); -} -``` - -我们发现,有些时候运气不好,得到的结果并不是2000个元素,而是: - -![image-20221004212332535](https://s2.loli.net/2022/10/04/m1nZfG4wPCOQx8V.png) - -因为之前的集合类,并没有考虑到多线程运行的情况,如果两个线程同时执行,那么有可能两个线程同一时间都执行同一个方法,这种情况下就很容易出问题: - -```java -public boolean add(E e) { - ensureCapacityInternal(size + 1); // 当数组容量更好还差一个满的时候,这个时候两个线程同时走到了这里,因为都判断为没满,所以说没有进行扩容,但是实际上两个线程都要插入一个元素进来 - elementData[size++] = e; //当两个线程同时在这里插入元素,直接导致越界访问 - return true; -} -``` - -当然,在Java早期的时候,还有一些老的集合类,这些集合类都是线程安全的: - -```java -public static void main(String[] args) throws InterruptedException { - Vector list = new Vector<>(); //我们可以使用Vector代替List使用 - //Hashtable 也可以使用Hashtable来代替Map - new Thread(() -> { - for (int i = 0; i < 1000; i++) { - list.add(i); - } - }).start(); - new Thread(() -> { - for (int i = 1000; i < 2000; i++) { - list.add(i); - } - }).start(); - - Thread.sleep(1000); - System.out.println(list.size()); -} -``` - -因为这些集合类中的每一个方法都加了锁,所以说不会出现多线程问题,但是这些老的集合类现在已经不再使用了,我们会在JUC篇视频教程中介绍专用于并发编程的集合类。 - -通过对Java多线程的了解,我们就具备了利用多线程解决问题的思维! - -### 实战:生产者与消费者 - -所谓的生产者消费者模型,是通过一个容器来解决生产者和消费者的强耦合问题。通俗的讲,就是生产者在不断的生产,消费者也在不断的消费,可是消费者消费的产品是生产者生产的,这就必然存在一个中间容器,我们可以把这个容器想象成是一个货架,当货架空的时候,生产者要生产产品,此时消费者在等待生产者往货架上生产产品,而当货架有货物的时候,消费者可以从货架上拿走商品,生产者此时等待货架出现空位,进而补货,这样不断的循环。 - -通过多线程编程,来模拟一个餐厅的2个厨师和3个顾客,假设厨师炒出一个菜的时间为3秒,顾客吃掉菜品的时间为4秒。 - -*** - -## 反射 - -**注意:**本章节涉及到JVM相关底层原理,难度会有一些大。 - -反射就是把Java类中的各个成分映射成一个个的Java对象。即在运行状态中,对于任意一个类,都能够知道这个类所有的属性和方法,对于任意一个对象,都能调用它的任意一个方法和属性。这种动态获取信息及动态调用对象方法的功能叫Java的反射机制。 - -简而言之,我们可以通过反射机制,获取到类的一些属性,包括类里面有哪些字段,有哪些方法,继承自哪个类,甚至还能获取到泛型!它的权限非常高,慎重使用! - -### Java类加载机制 - -在学习Java的反射机制之前,我们需要先了解一下类的加载机制,一个类是如何被加载和使用的: - -![image-20221004213335479](https://s2.loli.net/2022/10/04/vZ4onhuJWcALHNP.png) - -在Java程序启动时,JVM会将一部分类(class文件)先加载(并不是所有的类都会在一开始加载),通过ClassLoader将类加载,在加载过程中,会将类的信息提取出来(存放在元空间中,JDK1.8之前存放在永久代),同时也会生成一个Class对象存放在内存(堆内存),注意此Class对象只会存在一个,与加载的类唯一对应! - -为了方便各位小伙伴理解,你们就直接理解为默认情况下(仅使用默认类加载器)每个类都有且只有一个唯一的Class对象存放在JVM中,我们无论通过什么方式访问,都是始终是那一个对象。Class对象中包含我们类的一些信息,包括类里面有哪些方法、哪些变量等等。 - -### Class类详解 - -通过前面,我们了解了类的加载,同时会提取一个类的信息生成Class对象存放在内存中,而反射机制其实就是利用这些存放的类信息,来获取类的信息和操作类。那么如何获取到每个类对应的Class对象呢,我们可以通过以下方式: - -```java -public static void main(String[] args) throws ClassNotFoundException { - Class clazz = String.class; //使用class关键字,通过类名获取 - Class clazz2 = Class.forName("java.lang.String"); //使用Class类静态方法forName(),通过包名.类名获取,注意返回值是Class - Class clazz3 = new String("cpdd").getClass(); //通过实例对象获取 -} -``` - -注意Class类也是一个泛型类,只有第一种方法,能够直接获取到对应类型的Class对象,而以下两种方法使用了`?`通配符作为返回值,但是实际上都和第一个返回的是同一个对象: - -```java -Class clazz = String.class; //使用class关键字,通过类名获取 -Class clazz2 = Class.forName("java.lang.String"); //使用Class类静态方法forName(),通过包名.类名获取,注意返回值是Class -Class clazz3 = new String("cpdd").getClass(); - -System.out.println(clazz == clazz2); -System.out.println(clazz == clazz3); -``` - -通过比较,验证了我们一开始的结论,在JVM中每个类始终只存在一个Class对象,无论通过什么方法获取,都是一样的。现在我们再来看看这个问题: - -```java -public static void main(String[] args) { - Class clazz = int.class; //基本数据类型有Class对象吗? - System.out.println(clazz); -} -``` - -迷了,不是每个类才有Class对象吗,基本数据类型又不是类,这也行吗?实际上,基本数据类型也有对应的Class对象(反射操作可能需要用到),而且我们不仅可以通过class关键字获取,其实本质上是定义在对应的包装类中的: - -```java -/** - * The {@code Class} instance representing the primitive type - * {@code int}. - * - * @since JDK1.1 - */ -@SuppressWarnings("unchecked") -public static final Class TYPE = (Class) Class.getPrimitiveClass("int"); - -/* - * Return the Virtual Machine's Class object for the named - * primitive type - */ -static native Class getPrimitiveClass(String name); //C++实现,并非Java定义 -``` - -每个包装类中(包括Void),都有一个获取原始类型Class方法,注意,getPrimitiveClass获取的是原始类型,并不是包装类型,只是可以使用包装类来表示。 - -```java -public static void main(String[] args) { - Class clazz = int.class; - System.out.println(Integer.TYPE == int.class); -} -``` - -通过对比,我们发现实际上包装类型都有一个TYPE,其实也就是基本类型的Class,那么包装类的Class和基本类的Class一样吗? - -```java -public static void main(String[] args) { - System.out.println(Integer.TYPE == Integer.class); -} -``` - -我们发现,包装类型的Class对象并不是基本类型Class对象。数组类型也是一种类型,只是编程不可见,因此我们可以直接获取数组的Class对象: - -```java -public static void main(String[] args) { - Class clazz = String[].class; - System.out.println(clazz.getName()); //获取类名称(得到的是包名+类名的完整名称) - System.out.println(clazz.getSimpleName()); - System.out.println(clazz.getTypeName()); - System.out.println(clazz.getClassLoader()); //获取它的类加载器 - System.out.println(clazz.cast(new Integer("10"))); //强制类型转换 -} -``` - -下节课,我们将开始对Class对象的使用进行讲解。 - -### Class对象与多态 - -正常情况下,我们使用instanceof进行类型比较: - -```java -public static void main(String[] args) { - String str = ""; - System.out.println(str instanceof String); -} -``` - -它可以判断一个对象是否为此接口或是类的实现或是子类,而现在我们有了更多的方式去判断类型: - -```java -public static void main(String[] args) { - String str = ""; - System.out.println(str.getClass() == String.class); //直接判断是否为这个类型 -} -``` - -如果需要判断是否为子类或是接口/抽象类的实现,我们可以使用`asSubClass()`方法: - -```java -public static void main(String[] args) { - Integer i = 10; - i.getClass().asSubclass(Number.class); //当Integer不是Number的子类时,会产生异常 -} -``` - -通过`getSuperclass()`方法,我们可以获取到父类的Class对象: - -```java -public static void main(String[] args) { - Integer i = 10; - System.out.println(i.getClass().getSuperclass()); -} -``` - -也可以通过`getGenericSuperclass()`获取父类的原始类型的Type: - -```java -public static void main(String[] args) { - Integer i = 10; - Type type = i.getClass().getGenericSuperclass(); - System.out.println(type); - System.out.println(type instanceof Class); -} -``` - -我们发现Type实际上是Class类的父接口,但是获取到的Type的实现并不一定是Class。 - -同理,我们也可以像上面这样获取父接口: - -```java -public static void main(String[] args) { - Integer i = 10; - for (Class anInterface : i.getClass().getInterfaces()) { - System.out.println(anInterface.getName()); - } - - for (Type genericInterface : i.getClass().getGenericInterfaces()) { - System.out.println(genericInterface.getTypeName()); - } -} -``` - -是不是感觉反射功能很强大?几乎类的所有信息都可以通过反射获得。 - -### 创建类对象 - -既然我们拿到了类的定义,那么是否可以通过Class对象来创建对象、调用方法、修改变量呢?当然是可以的,那我们首先来探讨一下如何创建一个类的对象: - -```java -public static void main(String[] args) throws InstantiationException, IllegalAccessException { - Class clazz = Student.class; - Student student = clazz.newInstance(); - student.test(); -} - -static class Student{ - public void test(){ - System.out.println("萨日朗"); - } -} -``` - -通过使用`newInstance()`方法来创建对应类型的实例,返回泛型T,注意它会抛出InstantiationException和IllegalAccessException异常,那么什么情况下会出现异常呢? - -```java -public static void main(String[] args) throws InstantiationException, IllegalAccessException { - Class clazz = Student.class; - Student student = clazz.newInstance(); - student.test(); -} - -static class Student{ - - public Student(String text){ - - } - - public void test(){ - System.out.println("萨日朗"); - } -} -``` - -当类默认的构造方法被带参构造覆盖时,会出现InstantiationException异常,因为`newInstance()`只适用于默认无参构造。 - -```java -public static void main(String[] args) throws InstantiationException, IllegalAccessException { - Class clazz = Student.class; - Student student = clazz.newInstance(); - student.test(); -} - -static class Student{ - - private Student(){} - - public void test(){ - System.out.println("萨日朗"); - } -} -``` - -当默认无参构造的权限不是`public`时,会出现IllegalAccessException异常,表示我们无权去调用默认构造方法。在JDK9之后,不再推荐使用`newInstance()`方法了,而是使用我们接下来要介绍到的,通过获取构造器,来实例化对象: - -```java -public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { - Class clazz = Student.class; - Student student = clazz.getConstructor(String.class).newInstance("what's up"); - student.test(); -} - -static class Student{ - - public Student(String str){} - - public void test(){ - System.out.println("萨日朗"); - } -} -``` - -通过获取类的构造方法(构造器)来创建对象实例,会更加合理,我们可以使用`getConstructor()`方法来获取类的构造方法,同时我们需要向其中填入参数,也就是构造方法需要的类型,当然我们这里只演示了。那么,当访问权限不是public的时候呢? - -```java -public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { - Class clazz = Student.class; - Student student = clazz.getConstructor(String.class).newInstance("what's up"); - student.test(); -} - -static class Student{ - - private Student(String str){} - - public void test(){ - System.out.println("萨日朗"); - } -} -``` - -我们发现,当访问权限不足时,会无法找到此构造方法,那么如何找到非public的构造方法呢? - -```java -Class clazz = Student.class; -Constructor constructor = clazz.getDeclaredConstructor(String.class); -constructor.setAccessible(true); //修改访问权限 -Student student = constructor.newInstance("what's up"); -student.test(); -``` - -使用`getDeclaredConstructor()`方法可以找到类中的非public构造方法,但是在使用之前,我们需要先修改访问权限,在修改访问权限之后,就可以使用非public方法了(这意味着,反射可以无视权限修饰符访问类的内容) - -### 调用类方法 - -我们可以通过反射来调用类的方法(本质上还是类的实例进行调用)只是利用反射机制实现了方法的调用,我们在包下创建一个新的类: - -```java -package com.test; - -public class Student { - public void test(String str){ - System.out.println("萨日朗"+str); - } -} -``` - -这次我们通过`forName(String)`来找到这个类并创建一个新的对象: - -```java -public static void main(String[] args) throws ReflectiveOperationException { - Class clazz = Class.forName("com.test.Student"); - Object instance = clazz.newInstance(); //创建出学生对象 - Method method = clazz.getMethod("test", String.class); //通过方法名和形参类型获取类中的方法 - - method.invoke(instance, "what's up"); //通过Method对象的invoke方法来调用方法 -} -``` - -通过调用`getMethod()`方法,我们可以获取到类中所有声明为public的方法,得到一个Method对象,我们可以通过Method对象的`invoke()`方法(返回值就是方法的返回值,因为这里是void,返回值为null)来调用已经获取到的方法,注意传参。 - -我们发现,利用反射之后,在一个对象从构造到方法调用,没有任何一处需要引用到对象的实际类型,我们也没有导入Student类,整个过程都是反射在代替进行操作,使得整个过程被模糊了,过多的使用反射,会极大地降低后期维护性。 - -同构造方法一样,当出现非public方法时,我们可以通过反射来无视权限修饰符,获取非public方法并调用,现在我们将`test()`方法的权限修饰符改为private: - -```java -public static void main(String[] args) throws ReflectiveOperationException { - Class clazz = Class.forName("com.test.Student"); - Object instance = clazz.newInstance(); //创建出学生对象 - Method method = clazz.getDeclaredMethod("test", String.class); //通过方法名和形参类型获取类中的方法 - method.setAccessible(true); - - method.invoke(instance, "what's up"); //通过Method对象的invoke方法来调用方法 -} -``` - -Method和Constructor都和Class一样,他们存储了方法的信息,包括方法的形式参数列表,返回值,方法的名称等内容,我们可以直接通过Method对象来获取这些信息: - -```java -public static void main(String[] args) throws ReflectiveOperationException { - Class clazz = Class.forName("com.test.Student"); - Method method = clazz.getDeclaredMethod("test", String.class); //通过方法名和形参类型获取类中的方法 - - System.out.println(method.getName()); //获取方法名称 - System.out.println(method.getReturnType()); //获取返回值类型 -} -``` - -当方法的参数为可变参数时,我们该如何获取方法呢?实际上,我们在之前就已经提到过,可变参数实际上就是一个数组,因此我们可以直接使用数组的class对象表示: - -```java -Method method = clazz.getDeclaredMethod("test", String[].class); -``` - -反射非常强大,尤其是我们提到的越权访问,但是请一定谨慎使用,别人将某个方法设置为private一定有他的理由,如果实在是需要使用别人定义为private的方法,就必须确保这样做是安全的,在没有了解别人代码的整个过程就强行越权访问,可能会出现无法预知的错误。 - -### 修改类的属性 - -我们还可以通过反射访问一个类中定义的成员字段也可以修改一个类的对象中的成员字段值,通过`getField()`方法来获取一个类定义的指定字段: - -```java -public static void main(String[] args) throws ReflectiveOperationException { - Class clazz = Class.forName("com.test.Student"); - Object instance = clazz.newInstance(); - - Field field = clazz.getField("i"); //获取类的成员字段i - field.set(instance, 100); //将类实例instance的成员字段i设置为100 - - Method method = clazz.getMethod("test"); - method.invoke(instance); -} -``` - -在得到Field之后,我们就可以直接通过`set()`方法为某个对象,设定此属性的值,比如上面,我们就为instance对象设定值为100,当访问private字段时,同样可以按照上面的操作进行越权访问: - -```java -public static void main(String[] args) throws ReflectiveOperationException { - Class clazz = Class.forName("com.test.Student"); - Object instance = clazz.newInstance(); - - Field field = clazz.getDeclaredField("i"); //获取类的成员字段i - field.setAccessible(true); - field.set(instance, 100); //将类实例instance的成员字段i设置为100 - - Method method = clazz.getMethod("test"); - method.invoke(instance); -} -``` - -现在我们已经知道,反射几乎可以把一个类的老底都给扒出来,任何属性,任何内容,都可以被反射修改,无论权限修饰符是什么,那么,如果我的字段被标记为final呢?现在在字段`i`前面添加`final`关键字,我们再来看看效果: - -```java -private final int i = 10; -``` - -这时,当字段为final时,就修改失败了!当然,通过反射可以直接将final修饰符直接去除,去除后,就可以随意修改内容了,我们来尝试修改Integer的value值: - -```java -public static void main(String[] args) throws ReflectiveOperationException { - Integer i = 10; - - Field field = Integer.class.getDeclaredField("value"); - - Field modifiersField = Field.class.getDeclaredField("modifiers"); //这里要获取Field类的modifiers字段进行修改 - modifiersField.setAccessible(true); - modifiersField.setInt(field,field.getModifiers()&~Modifier.FINAL); //去除final标记 - - field.setAccessible(true); - field.set(i, 100); //强行设置值 - - System.out.println(i); -} -``` - -我们可以发现,反射非常暴力,就连被定义为final字段的值都能强行修改,几乎能够无视一切阻拦。我们来试试看修改一些其他的类型: - -```java -public static void main(String[] args) throws ReflectiveOperationException { - List i = new ArrayList<>(); - - Field field = ArrayList.class.getDeclaredField("size"); - field.setAccessible(true); - field.set(i, 10); - - i.add("测试"); //只添加一个元素 - System.out.println(i.size()); //大小直接变成11 - i.remove(10); //瞎移除都不带报错的,淦 -} -``` - -实际上,整个ArrayList体系由于我们的反射操作,导致被破坏,因此它已经无法正常工作了! - -再次强调,在进行反射操作时,必须注意是否安全,虽然拥有了创世主的能力,但是我们不能滥用,我们只能把它当做一个不得已才去使用的工具! - -### 类加载器 - -我们接着来介绍一下类加载器,实际上类加载器就是用于加载一个类的,但是类加载器并不是只有一个。 - -**思考:**既然说Class对象和加载的类唯一对应,那如果我们手动创建一个与JDK包名一样,同时类名也保持一致,JVM会加载这个类吗? - -```java -package java.lang; - -public class String { //JDK提供的String类也是 - public static void main(String[] args) { - System.out.println("我姓🐴,我叫🐴nb"); - } -} -``` - -我们发现,会出现以下报错: - -```java -错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为: - public static void main(String[] args) -``` - -但是我们明明在自己写的String类中定义了main方法啊,为什么会找不到此方法呢?实际上这是ClassLoader的`双亲委派机制`在保护Java程序的正常运行: - -![img](https://s2.loli.net/2022/10/04/5p6jdXDA8VtCEfN.png) - -实际上类最开始是由BootstarpClassLoader进行加载,BootstarpClassLoader用于加载JDK提供的类,而我们自己编写的类实际上是AppClassLoader加载的,只有BootstarpClassLoader都没有加载的类,才会让AppClassLoader来加载,因此我们自己编写的同名包同名类不会被加载,而实际要去启动的是真正的String类,也就自然找不到`main`方法了。 - -```java -public class Main { - public static void main(String[] args) { - System.out.println(Main.class.getClassLoader()); //查看当前类的类加载器 - System.out.println(Main.class.getClassLoader().getParent()); //父加载器 - System.out.println(Main.class.getClassLoader().getParent().getParent()); //爷爷加载器 - System.out.println(String.class.getClassLoader()); //String类的加载器 - } -} -``` - -由于BootstarpClassLoader是C++编写的,我们在Java中是获取不到的。 - -既然通过ClassLoader就可以加载类,那么我们可以自己手动将class文件加载到JVM中吗?先写好我们定义的类: - -```java -package com.test; - -public class Test { - public String text; - - public void test(String str){ - System.out.println(text+" > 我是测试方法!"+str); - } -} -``` - -通过javac命令,手动编译一个.class文件: - -```java -nagocoler@NagodeMacBook-Pro HelloWorld % javac src/main/java/com/test/Test.java -``` - -编译后,得到一个class文件,我们把它放到根目录下,然后编写一个我们自己的ClassLoader,因为普通的ClassLoader无法加载二进制文件,因此我们编写一个自定义的来让它支持: - -```java -//定义一个自己的ClassLoader -static class MyClassLoader extends ClassLoader{ - public Class defineClass(String name, byte[] b){ - return defineClass(name, b, 0, b.length); //调用protected方法,支持载入外部class文件 - } -} - -public static void main(String[] args) throws IOException { - MyClassLoader classLoader = new MyClassLoader(); - FileInputStream stream = new FileInputStream("Test.class"); - byte[] bytes = new byte[stream.available()]; - stream.read(bytes); - Class clazz = classLoader.defineClass("com.test.Test", bytes); //类名必须和我们定义的保持一致 - System.out.println(clazz.getName()); //成功加载外部class文件 -} -``` - -现在,我们就将此class文件读取并解析为Class了,现在我们就可以对此类进行操作了(注意,我们无法在代码中直接使用此类型,因为它是我们直接加载的),我们来试试看创建一个此类的对象并调用其方法: - -```java -try { - Object obj = clazz.newInstance(); - Method method = clazz.getMethod("test", String.class); //获取我们定义的test(String str)方法 - method.invoke(obj, "哥们这瓜多少钱一斤?"); -}catch (Exception e){ - e.printStackTrace(); -} -``` - -我们来试试看修改成员字段之后,再来调用此方法: - -```java -try { - Object obj = clazz.newInstance(); - Field field = clazz.getField("text"); //获取成员变量 String text; - field.set(obj, "华强"); - Method method = clazz.getMethod("test", String.class); //获取我们定义的test(String str)方法 - method.invoke(obj, "哥们这瓜多少钱一斤?"); -}catch (Exception e){ - e.printStackTrace(); -} -``` - -通过这种方式,我们就可以实现外部加载甚至是网络加载一个类,只需要把类文件传递即可,这样就无需再将代码写在本地,而是动态进行传递,不仅可以一定程度上防止源代码被反编译(只是一定程度上,想破解你代码有的是方法),而且在更多情况下,我们还可以对byte[]进行加密,保证在传输过程中的安全性。 - -*** - -## 注解 - -**注意:**注解跟我们之前讲解的注释完全不是一个概念,不要搞混了。 - -其实我们在之前就接触到注解了,比如`@Override`表示重写父类方法(当然不加效果也是一样的,此注解在编译时会被自动丢弃)注解本质上也是一个类,只不过它的用法比较特殊。 - -注解可以被标注在任意地方,包括方法上、类名上、参数上、成员属性上、注解定义上等,就像注释一样,它相当于我们对某样东西的一个标记。而与注释不同的是,注解可以通过反射在运行时获取,注解也可以选择是否保留到运行时。 - -### 预设注解 - -JDK预设了以下注解,作用于代码: - -- [@Override ]()- 检查(仅仅是检查,不保留到运行时)该方法是否是重写方法。如果发现其父类,或者是引用的接口中并没有该方法时,会报编译错误。 -- [@Deprecated ]()- 标记过时方法。如果使用该方法,会报编译警告。 -- [@SuppressWarnings ]()- 指示编译器去忽略注解中声明的警告(仅仅编译器阶段,不保留到运行时) -- [@FunctionalInterface ]()- Java 8 开始支持,标识一个匿名函数或函数式接口。 -- [@SafeVarargs ]()- Java 7 开始支持,忽略任何使用参数为泛型变量的方法或构造函数调用产生的警告。 - -### 元注解 - -元注解是作用于注解上的注解,用于我们编写自定义的注解: - -- [@Retention ]()- 标识这个注解怎么保存,是只在代码中,还是编入class文件中,或者是在运行时可以通过反射访问。 -- [@Documented ]()- 标记这些注解是否包含在用户文档中。 -- [@Target ]()- 标记这个注解应该是哪种 Java 成员。 -- [@Inherited ]()- 标记这个注解是继承于哪个注解类(默认 注解并没有继承于任何子类) -- [@Repeatable ]()- Java 8 开始支持,标识某注解可以在同一个声明上使用多次。 - -看了这么多预设的注解,你们肯定眼花缭乱了,那我们来看看`@Override`是如何定义的: - -```java -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.SOURCE) -public @interface Override { -} -``` - -该注解由`@Target`限定为只能作用于方法上,ElementType是一个枚举类型,用于表示此枚举的作用域,一个注解可以有很多个作用域。`@Retention`表示此注解的保留策略,包括三种策略,在上述中有写到,而这里定义为只在代码中。一般情况下,自定义的注解需要定义1个`@Retention`和1-n个`@Target`。 - -既然了解了元注解的使用和注解的定义方式,我们就来尝试定义一个自己的注解: - -```java -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface Test { -} -``` - -这里我们定义一个Test注解,并将其保留到运行时,同时此注解可以作用于方法或是类上: - -```java -@Test -public class Main { - @Test - public static void main(String[] args) { - - } -} -``` - -这样,一个最简单的注解就被我们创建了。 - -### 注解的使用 - -我们还可以在注解中定义一些属性,注解的属性也叫做成员变量,注解只有成员变量,没有方法。注解的成员变量在注解的定义中以“无形参的方法”形式来声明,其方法名定义了该成员变量的名字,其返回值定义了该成员变量的类型: - -```java -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -public @interface Test { - String value(); -} -``` - -默认只有一个属性时,我们可以将其名字设定为value,否则,我们需要在使用时手动指定注解的属性名称,使用value则无需填入: - -```java -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -public @interface Test { - String test(); -} -``` - -```java -public class Main { - @Test(test = "") - public static void main(String[] args) { - - } -} -``` - -我们也可以使用default关键字来为这些属性指定默认值: - -```java -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -public @interface Test { - String value() default "都看到这里了,给个三连吧!"; -} -``` - -当属性存在默认值时,使用注解的时候可以不用传入属性值。当属性为数组时呢? - -```java -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -public @interface Test { - String[] value(); -} -``` - -当属性为数组,我们在使用注解传参时,如果数组里面只有一个内容,我们可以直接传入一个值,而不是创建一个数组: - -```java -@Test("关注点了吗") -public static void main(String[] args) { - -} -``` - -```java -public class Main { - @Test({"value1", "value2"}) //多个值时就使用花括号括起来 - public static void main(String[] args) { - - } -} -``` - -### 反射获取注解 - -既然我们的注解可以保留到运行时,那么我们来看看,如何获取我们编写的注解,我们需要用到反射机制: - -```java -public static void main(String[] args) { - Class clazz = Student.class; - for (Annotation annotation : clazz.getAnnotations()) { - System.out.println(annotation.annotationType()); //获取类型 - System.out.println(annotation instanceof Test); //直接判断是否为Test - Test test = (Test) annotation; - System.out.println(test.value()); //获取我们在注解中写入的内容 - } -} -``` - -通过反射机制,我们可以快速获取到我们标记的注解,同时还能获取到注解中填入的值,那么我们来看看,方法上的标记是不是也可以通过这种方式获取注解: - -```java -public static void main(String[] args) throws NoSuchMethodException { - Class clazz = Student.class; - for (Annotation annotation : clazz.getMethod("test").getAnnotations()) { - System.out.println(annotation.annotationType()); //获取类型 - System.out.println(annotation instanceof Test); //直接判断是否为Test - Test test = (Test) annotation; - System.out.println(test.value()); //获取我们在注解中写入的内容 - } -} -``` - -无论是方法、类、还是字段,都可以使用`getAnnotations()`方法(还有几个同名的)来快速获取我们标记的注解。 - -所以说呢,这玩意学来有啥用?丝毫get不到这玩意的用处。其实不是,现阶段作为初学者,还体会不到注解带来的快乐,在接触到Spring和SpringBoot等大型框架后,相信各位就能感受到注解带来的魅力了。 - -*** - -## 结束语 - -Java的学习对你来说可能是枯燥的,可能是漫长的,也有可能是有趣的,无论如何,你终于是完成了全部内容的学习,可喜可贺。 - -实际上很多人一开始跟着你们一起在进行学习,但是他们因为各种原因,最后还是没有走完这条路。坚持不一定会成功,但坚持到别人坚持不下去,那么你至少已经成功了一半了,坚持到最后的人运气往往都不会太差。 - -希望各位小伙伴能够在之后的学习中砥砺前行! \ No newline at end of file diff --git a/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(三)重置版.md b/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(三)重置版.md deleted file mode 100644 index f379d0c..0000000 --- a/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(三)重置版.md +++ /dev/null @@ -1,1995 +0,0 @@ -![image-20220918121719900](https://s2.loli.net/2022/09/18/UsqxV8ndNzYmGjy.png) - -# 面向对象基础篇 - -我们在前面已经学习了面向过程编程,也可以自行编写出简单的程序了。我们接着就需要认识 面向对象程序设计(Object Oriented Programming)它是我们在Java语言中要学习的重要内容,面向对象也是高级语言的一大重要特性。 - -> 面向对象是新手成长的一道分水岭,有的人秒懂,有的人直到最后都无法理解。 - -这一章开始难度就上来了,所以说请各位小伙伴一定认真。 - -## 类与对象 - -类的概念我们在生活中其实已经听说过很多了。 - -人类、鸟类、鱼类... 所谓类,就是对一类事物的描述,是抽象的、概念上的定义,比如鸟类,就泛指所有具有鸟类特征的动物。比如人类,不同的人,有着不同的性格、不同的爱好、不同的样貌等等,但是他们根本上都是人,所以说可以将他们抽象描述为人类。 - -对象是某一类事物实际存在的每个个体,因而也被称为实例(instance)我们每个人都是人类的一个实际存在的个体。 - -![image-20220919203119479](https://s2.loli.net/2022/09/19/U2P7qWOtRz5bhFY.png) - -所以说,类就是抽象概念的人,而对象,就是具体的某一个人。 - -* A:是谁拿走了我的手机? -* B:是个人。(某一个类) -* A:我还知道是个人呢,具体是谁呢? -* B:是XXX。(具体某个对象) - -而我们在Java中,也可以像这样进行编程,我们可以定义一个类,然后进一步创建许多这个类的实例对象。像这种编程方式,我们称为**面向对象编程**。 - -### 类的定义与对象创建 - -前面我们介绍了什么是类,什么是对象,首先我们就来看看如何去定义一个类。 - -比如现在我们想要定义一个人类,我们可以右键`src`目录,点击创建新的类: - -![image-20220919204004526](https://s2.loli.net/2022/09/19/alOtdE1JNcbpxM8.png) - -我们在对类进行命名时,一般使用英文单词,并且首字母大写,跟变量命名一样,不能出现任何的特殊字符。 - -![image-20220919204159248](https://s2.loli.net/2022/09/19/n1WuVYRiPeOfHqZ.png) - -可以看到,现在我们的目录下有了两个`.java`源文件,其中一个是默认创建的Main.java,还有一个是我们刚刚创建的类。 - -我们来看看创建好之后,一个类写了哪些内容: - -```java -public class Person { - -} -``` - -可以发现,这不是跟一开始创建的Main中写的格式一模一样吗?没错,Main也是一个类,只不过我们一直都将其当做主类在使用,也就是编写主方法的类,关于方法我们会在后面进行介绍。 - -现在我们就创建好了一个类,既然是人类,那么肯定有人相关的一些属性,比如名字、性别、年龄等等,那么怎么才能给这个类添加一些属性呢? - -我们可以将这些属性直接作为类的成员变量(成员变量相当于是这个类所具有的属性,每个实例创建出来之后,这些属性都可能会各不相同)定义到类中。 - -```java -public class Person { //这里定义的人类具有三个属性,名字、年龄、性别 - String name; //直接在类中定义变量,表示类具有的属性 - int age; - String sex; -} -``` - -可能会有小伙伴疑问,这些变量啥时候被赋值呢?实际上这些变量只有在一个具体的对象中才可以使用。 - -那么现在人类的属性都规定好了,我们就可以尝试创建一个实例对象了,实例对应的应该是一个具体的人: - -```java -new 类名(); -``` - -```java -public static void main(String[] args) { - new Person(); //我们可以使用new关键字来创建某个类的对象,注意new后面需要跟上 类名() - //这里创建出来的,就是一个具体的人了 -} -``` - -实际上整个流程为: - -![image-20220919205550104](https://s2.loli.net/2022/09/19/dSM4XDBV7qkIUlb.png) - -只不过这里仅仅是创建出了这样的一个对象,我们目前没有办法去操作这个对象,比如想要修改或是获取这个人的名字等等。 - -### 对象的使用 - -既然现在我们知道如何创建对象,那么我们怎么去访问这个对象呢,比如我现在想要去查看或是修改它的名字。 - -我们同样可以使用一个变量来指代某个对象,只不过引用类型的变量,存储的是对象的引用,而不是对象本身: - -```java -public static void main(String[] args) { - //这里的a存放的是具体的某个值 - int a = 10; - //创建一个变量指代我们刚刚创建好的对象,变量的类型就是对应的类名 - //这里的p存放的是对象的引用,而不是本体,我们可以通过对象的引用来间接操作对象 - Person p = new Person(); -} -``` - -至于为什么对象类型的变量存放的是对象的引用,比如: - -```java -public static void main(String[] args) { - Person p1 = new Person(); - Person p2 = p1; -} -``` - -这里,我们将变量p2赋值为p1的值,那么实际上只是传递了对象的引用,而不是对象本身的复制,这跟我们前面的基本数据类型有些不同,p2和p1都指向的是同一个对象(如果你学习过C语言,它就类似于指针一样的存在) - -![image-20220919211443657](https://s2.loli.net/2022/09/19/GBPaNZsr2MSKvCq.png) - -我们可以来测试一下: - -```java -public static void main(String[] args) { - Person p1 = new Person(); - Person p2 = p1; - System.out.println(p1 == p2); //使用 == 可以判断两个变量引用的是不是同一个对象 -} -``` - -但是如果我们像这样去编写: - -```java -public static void main(String[] args) { - Person p1 = new Person(); //这两个变量分别引用的是不同的两个对象 - Person p2 = new Person(); - System.out.println(p1 == p2); //如果两个变量存放的是不同对象的引用,那么肯定就是不一样的了 -} -``` - -实际上我们之前使用的String类型,也是一个引用类型,我们会在下一章详细讨论。我们在上一章介绍的都是基本类型,而类使用的都是引用类型。 - -现在我们有了对象的引用之后,我们就可以进行操作了: - -![image-20220919210058797](https://s2.loli.net/2022/09/19/cEJ1CWshtQFbZzy.png) - -我们可以直接访问对象的一些属性,也就是我们在类中定义好的那些,对于不同的对象,这些属性都具体存放值也会不同。 - -比如我们可以修改对象的名字: - -```java -public static void main(String[] args) { - Person p = new Person(); - p.name = "小明"; //要访问对象的属性,我们需要使用 . 运算符 - System.out.println(p.name); //直接打印对象的名字,就是我们刚刚修改好的结果了 -} -``` - -注意,不同对象的属性是分开独立存放的,每个对象都有一个自己的空间,修改一个对象的属性并不会影响到其他对象: - -```java -public static void main(String[] args) { - Person p1 = new Person(); - Person p2 = new Person(); - p1.name = "小明"; //这个修改的是第一个对象的属性 - p2.name = "大明"; //这里修改的是第二个对象的属性 - System.out.println(p1.name); //这里我们获取的是第一个对象的属性 -} -``` - -关于对象类型的变量,我们也可以不对任何对象进行引用: - -```java -public static void main(String[] args) { - Person p1 = null; //null是一个特殊的值,它表示空,也就是不引用任何的对象 -} -``` - -注意,如果不引用任何的对象,那肯定是不应该去通过这个变量去操作所引用的对象的(都没有引用对象,我操作谁啊我) - -虽然这样可以编译通过,但是在运行时会出现问题: - -```java -public static void main(String[] args) { - Person p = null; //此时变量没有引用任何对象 - p.name = "小红"; //我任性,就是要操作 - System.out.println(p.name); -} -``` - -我们来尝试运行一下这段代码: - -![image-20220919213732810](https://s2.loli.net/2022/09/19/hkME1wf58aSdWGZ.png) - -此时程序在运行的过程中,出现了异常,虽然我们还没有学习到异常,但是各位可以将异常理解为程序在运行过程中出现了问题,此时不得不终止程序退出。 - -这里出现的是空指针异常,很明显是因为我们去操作一个值为null的变量导致的。在我们以后的学习中,这个异常是出现频率最高的。 - -我们来看最后一个问题,对象创建成功之后,它的属性没有进行赋值,但是我们前面说了,变量使用之前需要先赋值,那么创建对象之后能否直接访问呢? - -```java -public static void main(String[] args) { - Person p = new Person(); - System.out.println("name = "+p.name); - System.out.println("age = "+p.age); - System.out.println("sex = "+p.sex); -} -``` - -我们来看看运行结果: - -![image-20220919214248053](https://s2.loli.net/2022/09/19/zDRdFwhm6nebSJU.png) - -我们可以看到,如果直接创建对象,那么对象的属性都会存在初始值,如果是基本类型,那么默认是统一为`0`(如果是boolean的话,默认值为false)如果是引用类型,那么默认是`null`。 - -### 方法创建与使用 - -前面我们介绍了类的定义以及对象的创建和使用。 - -现在我们的类有了属性,我们可以为创建的这些对象设定不同的属性值,比如每个人的名字都不一样,性别不一样,年龄不一样等等。只不过光有属性还不行,对象还需要具有一定的行为,就像我们人可以行走,可以跳跃,可以思考一样。 - -而对象也可以做出一些行为,我们可以通过定义方法来实现(在C语言中叫做函数) - -方法是语句的集合,是为了完成某件事情而存在的。完成某件事情,可以有结果,也可以做了就做了,不返回结果。比如计算两个数字的和,我们需要得到计算后的结果,所以说方法需要有返回值;又比如,我们只想吧数字打印在控制台,只需要打印就行,不用给我结果,所以说方法不需要有返回值。 - -方法的定义如下: - -``` -返回值类型 方法名称() { - 方法体... -} -``` - -首先是返回值类型,也就是说这个方法完成任务之后,得到的结果的数据类型(可以是基本类型,也可以是引用类型)当然,如果没有返回值,只是完成任务,那么可以使用`void`表示没有返回值,比如我们现在给人类编写一个自我介绍的行为: - -```java -public class Person { - String name; - int age; - String sex; - - //自我介绍只需要完成就行,没有返回值,所以说使用void - void hello(){ - //完成自我介绍需要执行的所有代码就在这个花括号中编写 - //这里编写代码跟我们之前在main中是一样的(实际上main就是一个函数) - //自我介绍需要用到当前对象的名字和年龄,我们直接使用成员变量即可,变量的值就是当前对象的存放值 - System.out.println("我叫 "+name+" 今年 "+age+" 岁了!"); - } -} -``` - -注意,方法名称同样可以随便起,但是规则跟变量的命名差不多,也是尽量使用小写字母开头的单词,如果是多个单词,一般使用驼峰命名法最规范。 - -![image-20220920101033325](https://s2.loli.net/2022/09/20/2vmhsCRXpPzojiD.png) - -现在我们给人类定义好了一个方法(行为)那么怎么才能让对象执行这个行为呢? - -```java -public static void main(String[] args) { - Person p = new Person(); - p.name = "小明"; - p.age = 18; - p.hello(); //我们只需要使用 . 运算符,就可以执行定义好的方法了,只需要 .方法名称() 即可 -} -``` - -像这样执行定义好的方法,我们一般称为**方法的调用**,我们来看看效果: - -![image-20220919220837991](https://s2.loli.net/2022/09/19/bR2PAWoJ8qUzCfh.png) - -比如现在我们要让人类学会加法运算,我们也可以通过定义一个方法的形式来完成,只不过,要完成加法运算,我们需要别人给人类提供两个参与加法运算的值才可以,所以我们这里就要用到参数了: - -```java -//我们的方法需要别人提供参与运算的值才可以 -//我们可以为方法设定参数,在调用方法时,需要外部传入参数才可以 -//参数的定义需要在小括号内部编写,类似于变量定义,需要填写 类型和参数名称,多个参数用逗号隔开 -int sum(int a, int b){ //这里需要两个int类型的参数进行计算 - -} -``` - -那么现在参数从外部传入之后,我们怎么使用呢? - -```java -int sum(int a, int b){ //这里的参数,相当于我们在函数中定义了两个局部变量,我们可以直接在方法中使用 - int c = a + b; //直接c = a + b -} -``` - -那么现在计算完成了,我们该怎么将结果传递到外面呢?首先函数的返回值是int类型,我们只需要使用`return`关键字来返回一个int类型的结果就可以了: - -```java -int sum(int a, int b){ - int c = a + b; - return c; //return后面紧跟需要返回的结果,这样就可以将计算结果丢出去了 - //带返回值的方法,是一定要有一个返回结果的!否则无法通过编译! -} -``` - -我们来测试一下吧: - -```java -public static void main(String[] args) { - Person p = new Person(); - p.name = "小明"; - p.age = 18; - int result = p.sum(10, 20); //现在我们要让这个对象帮我们计算10 + 20的结果 - System.out.println(result); //成功得到30,实际上这里的println也是在调用方法进行打印操作 -} -``` - -**注意:**方法定义时编写的参数,我们一般称为形式参数,而调用方法实际传入的参数,我们成为实际参数。 - -是不是越来越感觉我们真的在跟一个对象进行交互?只要各位有了这样的体验,基本上就已经摸到面向对象的门路了。 - -关于`return`关键字,我们还需要进行进一步的介绍。 - -在我们使用`return`关键字之后,方法就会直接结束并返回结果,所以说在这之后编写的任何代码,都是不可到达的: - -![image-20220919222813469](https://s2.loli.net/2022/09/19/UCcAb9L8lfOzXMZ.png) - -在`return`后编写代码,会导致编译不通过,因为存在不可达语句。 - -如果我们的程序中出现了分支语句,那么必须保证每一个分支都有返回值才可以: - -![image-20220919223037197](https://s2.loli.net/2022/09/19/WjUlRrPwA9EXThV.png) - -只要有任何一个分支缺少了`return`语句,都无法正常通过编译,总之就是必须考虑到所有的情况,任何情况下都必须要有返回值。 - -当然,如果方法没有返回值,我们也可以使用`return`语句,不需要跟上任何内容,只不过这种情况下使用,仅仅是为了快速结束方法的执行: - -```java -void test(int a){ - if(a == 10) return; //当a等于10时直接结束方法,后面无论有没有代码都不会执行了 - System.out.println("Hello World!"); //不是的情况就正常执行 -} -``` - -最后我们来讨论一下参数的传递问题: - -```java -void test(int a){ //我们可以设置参数来让外部的数据传入到函数内部 - System.out.println(a); -} -``` - -实际上参数的传递,会在调用方法的时候,对参数的值进行复制,方法中的参数变量,不是我们传入的变量本身,我们来下面的这个例子: - -```java -void swap(int a, int b){ //这个函数的目的很明显,就是为了交换a和b的值 - int tmp = a; - a = b; - b = a; -} -``` - -那么我们来测试一下: - -```java -public static void main(String[] args) { - Person p = new Person(); - int a = 5, b = 9; //外面也叫a和b - p.swap(a, b); - System.out.println("a = "+a+", b = "+b); //最后的结果会变成什么样子呢? -} -``` - -我们来看看结果是什么: - -![image-20220919224219071](https://s2.loli.net/2022/09/19/wJrLaT7YBeQipNV.png) - -我们发现a和b的值并没有发生交换,但是按照我们的方法逻辑来说,应该是会交换才对,这是为什么呢?实际上这里仅仅是将值复制给了函数里面的变量而已(相当于是变量的赋值) - -![image-20220919224623727](https://s2.loli.net/2022/09/19/WdiDToucsCvySNf.png) - -所以说我们交换的仅仅是方法中的a和b,参数传递仅仅是值传递,我们是没有办法直接操作到外面的a和b的。 - -那么各位小伙伴看看下面的例子: - -```java -void modify(Person person){ - person.name = "lbwnb"; //修改对象的名称 -} -``` - -```java -public static void main(String[] args) { - Person p = new Person(); - p.name = "小明"; //先在外面修改一次 - p.modify(p); //调用方法再修改一次 - System.out.println(p.name); //请问最后name会是什么? -} -``` - -我们来看看结果: - -![image-20220919224957971](https://s2.loli.net/2022/09/19/sNLjlYP6g3yxpe1.png) - -不对啊,前面不是说只是值传递吗,怎么这里又可以修改成功呢? - -确实,这里同样是进行的值传递,只不过各位小伙伴别忘了,我们前面可是说的清清楚楚,引用类型的变量,仅仅存放的是对象的引用,而不是对象本身。那么这里进行了值传递,相当于将对象的引用复制到了方法内部的变量中,而这个内部的变量,依然是引用的同一个对象,所以说这里在方法内操作,相当于直接操作外面的定义对象。 - -![image-20220919225455752](https://s2.loli.net/2022/09/19/aXf6AsdLneKxi9V.png) - -### 方法进阶使用 - -有时候我们的方法中可能会出现一些与成员变量重名的变量: - -```java -//我们希望使用这个方法,来为当前对象设定名字 -void setName(String name) { - -} -``` - -此时类中定义的变量名称也是`name`,那么我们是否可以这样编写呢: - -```java -void setName(String name) { - name = name; //出现重名时,优先使用作用域最接近的,这里实际上是将方法参数的局部变量name赋值为本身 -} -``` - -我们来测试一下: - -```java -public static void main(String[] args) { - Person p = new Person(); - p.setName("小明"); - System.out.println(p.name); -} -``` - -我们发现,似乎这样做并没有任何的效果,name依然是没有修改的状态。那么当出现重名的时候,因为默认情况下会优先使用作用域最近的变量,我们怎么才能表示要使用的变量是类的成员变量呢? - -```java -Person p = new Person(); -p.name = "小明"; //我们之前在外面使用时,可以直接通过对象.属性的形式访问到 -``` - -同样的,我们如果想要在方法中访问到当前对象的属性,那么可以使用`this`关键字,来明确表示当前类的示例对象本身: - -```java -void setName(String name) { - this.name = name; //让当前对象的name变量值等于参数传入的值 -} -``` - -这样就可以修改成功了,当然,如果方法内没有变量出现重名的情况,那么默认情况下可以不使用`this`关键字来明确表示当前对象: - -```java -String getName() { - return name; //这里没有使用this,但是当前作用域下只有对象属性的name变量,所以说直接就使用了 -} -``` - -我们接着来看方法的重载。 - -有些时候,参数类型可能会多种多样,我们的方法需要能够同时应对多种情况: - -```java -int sum(int a, int b){ - return a + b; -} -``` - -```java -public static void main(String[] args) { - Person p = new Person(); - System.out.println(p.sum(10, 20)); //这里可以正常计算两个整数的和 -} -``` - -但是要是我们现在不仅要让人类会计算整数,还要会计算小数呢? - -![image-20220920102347110](https://s2.loli.net/2022/09/20/m7BvM1RctLznhrA.png) - -当我们使用小数时,可以看到,参数要求的是int类型,那么肯定会出现错误,这个方法只能用于计算整数。此时,为了让这个方法支持使用小数进行计算,我们可以将这个方法进行重载。 - -一个类中可以包含多个同名的方法,但是需要的形式参数不一样,方法的返回类型,可以相同,也可以不同,但是仅返回类型不同,是不允许的! - -```java -int sum(int a, int b){ - return a + b; -} - -double sum(double a, double b){ //为了支持小数加法,我们可以进行一次重载 - return a + b; -} -``` - -这样就可以正常使用了: - -```java -public static void main(String[] args) { - Person p = new Person(); - //当方法出现多个重载的情况,在调用时会自动进行匹配,选择合适的方法进行调用 - System.out.println(p.sum(1.5, 2.2)); -} -``` - -包括我们之前一直在使用的`println`方法,其实也是重载了很多次的,因为要支持各种值的打印。 - -注意,如果仅仅是返回值的不同,是不支持重载的: - -![image-20220920102933047](https://s2.loli.net/2022/09/20/N2TRuqEnxrKbpc8.png) - -当然,方法之间是可以相互调用的: - -```java -void test(){ - System.out.println("我是test"); //实际上这里也是调用另一个方法 -} - -void say(){ - test(); //在一个方法内调用另一个方法 -} -``` - -如果我们这样写的话: - -```java -void test(){ - say(); -} - -void say(){ - test(); -} -``` - -各位猜猜看会出现什么情况? - -![image-20220921001914601](https://s2.loli.net/2022/09/21/XPMVa3pdBcFICTE.png) - -此时又出现了一个我们不认识的异常,实际上什么原因导致的我们自己都很清楚,方法之间一直在相互调用,没有一个出口。 - -方法自己也可以调用自己: - -```java -void test(){ - test(); -} -``` - -像这样自己调用自己的行为,我们称为递归调用,如果直接这样编写,会跟上面一样,出现栈溢出错误。但是如果我们给其合理地设置出口,就不会出现这种问题,比如我们想要计算从1加到n的和: - -```java -int test(int n){ - if(n == 0) return 0; - return test(n - 1) + n; //返回的结果是下一层返回的结果+当前这一层的n -} -``` - -是不是感觉很巧妙?实际上递归调用在很多情况下能够快速解决一些很麻烦的问题,我们会在后面继续了解。 - -### 构造方法 - -我们接着来看一种比较特殊的方法,构造方法。 - -我们前面创建对象,都是直接使用`new`关键字就能直接搞定了,但是我们发现,对象在创建之后,各种属性都是默认值,那么能否实现在对象创建时就为其指定名字、年龄、性别呢?要在对象创建时进行处理,我们可以使用构造方法(构造器)来完成。 - -实际上每个类都有一个默认的构造方法,我们可以来看看反编译的结果: - -```java -public class Person { - String name; - int age; - String sex; - - public Person() { //反编译中,多出来了这样一个方法,这其实就是构造方法 - } -} -``` - -构造方法不需要填写返回值,并且方法名称与类名相同,默认情况下每个类都会自带一个没有任何参数的无参构造方法(只是不用我们去写,编译出来就自带)当然,我们也可以手动声明,对其进行修改: - -```java -public class Person { - String name; - int age; - String sex; - - Person(){ //构造方法不需要指定返回值,并且方法名称与类名相同 - name = "小明"; //构造方法会在对象创建时执行,我们可以将各种需要初始化的操作都在这里进行处理 - age = 18; - sex = "男"; - } -} -``` - -构造方法会在new的时候自动执行: - -```java -public static void main(String[] args) { - Person p = new Person(); //这里的new Person()其实就是在调用无参构造方法 - System.out.println(p.name); -} -``` - -当然,我们也可以为构造方法设定参数: - -```java -public class Person { - String name; - int age; - String sex; - - Person(String name, int age, String sex){ //跟普通方法是一样的 - this.name = name; - this.age = age; - this.sex = sex; - } -} -``` - -注意,在我们自己定义一个构造方法之后,会覆盖掉默认的那一个无参构造方法,除非我们手动重载一个无参构造,否则要创建这个类的对象,必须调用我们自己定义的构造方法: - -```java -public static void main(String[] args) { - Person p = new Person("小明", 18, "男"); //调用自己定义的带三个参数的构造方法 - System.out.println(p.name); -} -``` - -我们可以去看看反编译的结果,会发现此时没有无参构造了,而是只剩下我们自己编写的。 - -当然,要给成员变量设定初始值,我们不仅可以通过构造方法,也可以直接在定义时赋值: - -```java -public class Person { - String name = "未知"; //直接赋值,那么对象构造好之后,属性默认就是这个值 - int age = 10; - String sex = "男"; -} -``` - -这里需要特别注意,成员变量的初始化,并不是在构造方法之前之后,而是在这之前就已经完成了: - -```java -Person(String name, int age, String sex){ - System.out.println(age); //在赋值之前看看是否有初始值 - this.name = name; - this.age = age; - this.sex = sex; -} -``` - -我们也可以在类中添加代码块,代码块同样会在对象构造之前进行,在成员变量初始化之后执行: - -```java -public class Person { - String name; - int age; - String sex; - - { - System.out.println("我是代码块"); //代码块中的内容会在对象创建时仅执行一次 - } - - Person(String name, int age, String sex){ - System.out.println("我被构造了"); - this.name = name; - this.age = age; - this.sex = sex; - } -} -``` - -只不过一般情况下使用代码块的频率比较低,标准情况下还是通过构造方法进行进行对象初始化工作,所以说这里做了解就行了。 - -### 静态变量和静态方法 - -前面我们已经了解了类的大部分特性,一个类可以具有多种属性、行为,包括对象该如何创建,我们可以通过构造方法进行设定,我们可以通过类创建对象,每个对象都会具有我们在类中设定好的属性,包括我们设定好的行为,所以说类就像是一个模板,我们可以通过这个模板快速捏造出一个又一个的对象。我们接着来看比较特殊的静态特性。 - -静态的内容,我们可以理解为是属于这个类的,也可以理解为是所有对象共享的内容。我们通过使用`static`关键字来声明一个变量或一个方法为静态的,一旦被声明为静态,那么通过这个类创建的所有对象,操作的都是同一个目标,也就是说,对象再多,也只有这一个静态的变量或方法。一个对象改变了静态变量的值,那么其他的对象读取的就是被改变的值。 - -```java -public class Person { - String name; - int age; - String sex; - static String info; //这里我们定义一个info静态变量 -} -``` - -我们来测试一下: - -```java -public static void main(String[] args) { - Person p1 = new Person(); - Person p2 = new Person(); - p1.info = "杰哥你干嘛"; - System.out.println(p2.info); //可以看到,由于静态属性是属于类的,因此无论通过什么方式改变,都改变的是同一个目标 -} -``` - -所以说一般情况下,我们并不会通过一个具体的对象去修改和使用静态属性,而是通过这个类去使用: - -```java -public static void main(String[] args) { - Person.info = "让我看看"; - System.out.println(Person.info); -} -``` - -同样的,我们可以将方法标记为静态: - -```java -static void test(){ - System.out.println("我是静态方法"); -} -``` - -静态方法同样是属于类的,而不是具体的某个对象,所以说,就像下面这样: - -![image-20220920234401275](https://s2.loli.net/2022/09/20/cWCrJgnkXFL63y2.png) - -因为静态方法属于类的,所以说我们在静态方法中,无法获取成员变量的值: - -![image-20220920235418115](https://s2.loli.net/2022/09/20/XvPjtLm2wOMh4ZK.png) - -成员变量是某个具体对象拥有的属性,就像小明这个具体的人的名字才叫小明,而静态方法是类具有的,并不是具体对象的,肯定是没办法访问到的。同样的,在静态方法中,无法使用`this`关键字,因为this关键字代表的是当前的对象本身。 - -但是静态方法是可以访问到静态变量的: - -```java -static String info; - -static void test(){ - System.out.println("静态变量的值为:"+info); -} -``` - -因为他们都属于类,所以说肯定是可以访问到的。 - -我们也可以将代码块变成静态的: - -```java -static String info; - -static { //静态代码块可以用于初始化静态变量 - info = "测试"; -} -``` - -那么,静态变量,是在什么时候进行初始化的呢? - -我们在一开始介绍了,我们实际上是将`.class`文件丢给JVM去执行的,而每一个`.class`文件其实就是我们编写的一个类,我们在Java中使用一个类之前,JVM并不会在一开始就去加载它,而是在需要时才会去加载(优化)一般遇到以下情况时才会会加载类: - -- 访问类的静态变量,或者为静态变量赋值 -- new 创建类的实例(隐式加载) -- 调用类的静态方法 -- 子类初始化时 -- 其他的情况会在讲到反射时介绍 - -所有被标记为静态的内容,会在类刚加载的时候就分配,而不是在对象创建的时候分配,所以说静态内容一定会在第一个对象初始化之前完成加载。 - -我们可以来测试一下: - -```java -public class Person { - String name = test(); //这里我们用test方法的返回值作为变量的初始值,便于观察 - int age; - String sex; - - { - System.out.println("我是普通代码块"); - } - - Person(){ - System.out.println("我是构造方法"); - } - - String test(){ - System.out.println("我是成员变量初始化"); - return "小明"; - } - - static String info = init(); //这里我们用init静态方法的返回值作为变量的初始值,便于观察 - - static { - System.out.println("我是静态代码块"); - } - - static String init(){ - System.out.println("我是静态变量初始化"); - return "test"; - } -} -``` - -现在我们在主方法中创建一个对象,观察这几步是怎么在执行的: - -![image-20220921000953525](https://s2.loli.net/2022/09/21/JxTPk8SWtDmK6IX.png) - -可以看到,确实是静态内容在对象构造之前的就完成了初始化,实际上就是类初始化时完成的。 - -当然,如果我们直接访问类的静态变量: - -```java -public static void main(String[] args) { - System.out.println(Person.info); -} -``` - -那么此时同样会使得类初始化,进行加载: - -![image-20220921001222465](https://s2.loli.net/2022/09/21/auMJOvNfx9K3mzd.png) - -可以看到,在使用时,确实是先将静态内容初始化之后,才得到值的。当然,如果我们压根就没有去使用这个类,那么也不会被初始化了。 - -有关类与对象的基本内容,我们就全部讲解完毕了。 - -*** - -## 包和访问控制 - -通过前面的学习,我们知道该如何创建和使用类。 - -### 包声明和导入 - -包其实就是用来区分类位置的东西,也可以用来将我们的类进行分类(类似于C++中的namespace)随着我们的程序不断变大,可能会创建各种各样的类,他们可能会做不同的事情,那么这些类如果都放在一起的话,有点混乱,我们可以通过包的形式将这些类进行分类存放。 - -包的命名规则同样是英文和数字的组合,最好是一个域名的格式,比如我们经常访问的`www.baidu.com`,后面的baidu.com就是域名,我们的包就可以命名为`com.baidu`,当然,各位小伙伴现在还没有自己的域名,所以说我们随便起一个名称就可以了。其中的`.`就是用于分割的,对应多个文件夹,比如`com.test`: - -![image-20220921120040350](https://s2.loli.net/2022/09/21/OZdDi1sGluyjbgr.png) - -我们可以将类放入到包中: - -![image-20220921115055000](https://s2.loli.net/2022/09/21/e3GvFsHDhMAtBWR.png) - -我们之前都是直接创建的类,所以说没有包这个概念,但是现在,我们将类放到包中,就需要注意了: - -```java -package com.test; //在放入包中,需要在类的最上面添加package关键字来指明当前类所处的包 - -public class Main { //将Main类放到com.test这个包中 - public static void main(String[] args) { - - } -} -``` - -这里又是一个新的关键字`package`,这个是用于指定当前类所处的包的,注意,所处的包和对应的目录是一一对应的。 - -不同的类可以放在不同的包下: - -![image-20220921120241184](https://s2.loli.net/2022/09/21/stOGnxaPirZvjLF.png) - -当我们使用同一个包中的类时,直接使用即可(之前就是直接使用的,因为都直接在一个缺省的包中)而当我们需要使用其他包中的类时,需要先进行导入才可以: - -```java -package com.test; - -import com.test.entity.Person; //使用import关键字导入其他包中的类 - -public class Main { - public static void main(String[] args) { - Person person = new Person(); //只有导入之后才可以使用,否则编译器不知道这个类从哪来的 - } -} -``` - -这里使用了`import`关键字导入我们需要使用的类,当然,只有在类不在同一个包下时才需要进行导入,如果一个包中有多个类,我们可以使用`*`表示导入这个包中全部的类: - -```java -import com.test.entity.*; -``` - -实际上我们之前一直在使用的`System`类,也是在一个包中的: - -```java -package java.lang; - -import java.io.*; -import java.lang.reflect.Executable; -import java.lang.annotation.Annotation; -import java.security.AccessControlContext; -import java.util.Properties; -import java.util.PropertyPermission; -import java.util.StringTokenizer; -import java.util.Map; -import java.security.AccessController; -import java.security.PrivilegedAction; -import java.security.AllPermission; -import java.nio.channels.Channel; -import java.nio.channels.spi.SelectorProvider; -import sun.nio.ch.Interruptible; -import sun.reflect.CallerSensitive; -import sun.reflect.Reflection; -import sun.security.util.SecurityConstants; -import sun.reflect.annotation.AnnotationType; - -import jdk.internal.util.StaticProperty; - -/** - * The System class contains several useful class fields - * and methods. It cannot be instantiated. - * - *

Among the facilities provided by the System class - * are standard input, standard output, and error output streams; - * access to externally defined properties and environment - * variables; a means of loading files and libraries; and a utility - * method for quickly copying a portion of an array. - * - * @author unascribed - * @since JDK1.0 - */ -public final class System { - ... -} -``` - -可以看到它是属于`java.lang`这个包下的类,并且这个类也导入了很多其他包中的类在进行使用。那么,为什么我们在使用这个类时,没有导入呢?实际上Java中会默认导入`java.lang`这个包下的所有类,因此我们不需要手动指定。 - -IDEA非常智能,我们在使用项目中定义的类时,会自动帮我们将导入补全,所以说代码写起来非常高效。 - -注意,在不同包下的类,即使类名相同,也是不同的两个类: - -```java -package com.test.entity; - -public class String { //我们在自己的包中也建一个名为String的类 -} -``` - -当我们在使用时: - -![image-20220921121404900](/Users/nagocoler/Library/Application Support/typora-user-images/image-20220921121404900.png) - -由于默认导入了系统自带的String类,并且也导入了我们自己定义的String类,那么此时就出现了歧义,编译器不知道到底我们想用的是哪一个String类,所以说我们需要明确指定: - -```java -public class Main { - public static void main(java.lang.String[] args) { //主方法的String参数是java.lang包下的,我们需要明确指定一下,只需要在类名前面添加包名就行了 - com.test.entity.String string = new com.test.entity.String(); - } -} -``` - -我们只需要在类名前面把完整的包名也给写上,就可以表示这个是哪一个包里的类了,当然,如果没有出现歧义,默认情况下包名是可以省略的,可写可不写。 - -可能各位小伙伴会发现一个问题,为什么对象的属性访问不了了? - -![image-20220921122514457](https://s2.loli.net/2022/09/21/UaqMihmIQkzHFtG.png) - -编译器说name属性在这个类中不是public,无法在外部进行访问,这是什么情况呢?这里我们就要介绍的到Java的访问权限控制了。 - -### 访问权限控制 - -实际上Java中是有访问权限控制的,就是我们个人的隐私的一样,我不允许别人随便来查看我们的隐私,只有我们自己同意的情况下,才能告诉别人我们的名字、年龄等隐私信息。 - -所以说Java中引入了访问权限控制(可见性),我们可以为成员变量、成员方法、静态变量、静态方法甚至是类指定访问权限,不同的访问权限,有着不同程度的访问限制: - -* `private` - 私有,标记为私有的内容无法被除当前类以外的任何位置访问。 -* `什么都不写` - 默认,默认情况下,只能被类本身和同包中的其他类访问。 -* `protected` - 受保护,标记为受保护的内容可以能被类本身和同包中的其他类访问,也可以被子类访问(子类我们会在下一章介绍) -* `public` - 公共,标记为公共的内容,允许在任何地方被访问。 - -这四种访问权限,总结如下表: - -| | 当前类 | 同一个包下的类 | 不同包下的子类 | 不同包下的类 | -| :-------: | :----: | :------------: | :------------: | :----------: | -| public | ✅ | ✅ | ✅ | ✅ | -| protected | ✅ | ✅ | ✅ | ❌ | -| 默认 | ✅ | ✅ | ❌ | ❌ | -| private | ✅ | ❌ | ❌ | ❌ | - -比如我们刚刚出现的情况,就是因为是默认的访问权限,所以说在当前包以外的其他包中无法访问,但是我们可以提升它的访问权限,来使得外部也可以访问: - -```java -public class Person { - public String name; //在name变量前添加public关键字,将其可见性提升为公共等级 - int age; - String sex; -} -``` - -这样我们就可以在外部正常使用这个属性了: - -```java -public static void main(String[] args) { - Person person = new Person(); - System.out.println(person.name); //正常访问到成员变量 -} -``` - -实际上如果各位小伙伴观察仔细的话,会发现我们创建出来的类自带的访问等级就是`public`: - -```java -package com.test.entity; - -public class Person { //class前面有public关键字 - -} -``` - -也就是说这个类实际上可以在任何地方使用,但是我们也可以将其修改为默认的访问等级: - -```java -package com.test.entity; - -class Person { //去掉public变成默认等级 - -} -``` - -如果是默认等级的话,那么在外部同样是无法访问的: - -![image-20220921142724239](https://s2.loli.net/2022/09/21/ZTRAEItQY6UcqvP.png) - -但是注意,我们创建的普通类不能是`protected`或是`private`权限,因为我们目前所使用的普通类要么就是只给当前的包内使用,要么就是给外面都用,如果是`private`谁都不能用,那这个类定义出来干嘛呢? - -如果某个类中存在静态方法或是静态变量,那么我们可以通过静态导入的方式将其中的静态方法或是静态变量直接导入使用,但是同样需要有访问权限的情况下才可以: - -```java -public class Person { - String name; - int age; - String sex; - - public static void test(){ - System.out.println("我是静态方法!"); - } -} -``` - -我们来尝试一下静态导入: - -```java -import static com.test.entity.Person.test; //静态导入test方法 - -public class Main { - public static void main(String[] args) { - test(); //直接使用就可以,就像在这个类定义的方法一样 - } -} -``` - -至此,有关包相关的内容,我们就讲解到这里。 - -*** - -## 封装、继承和多态 - -封装、继承和多态是面向对象编程的三大特性。 - -> 封装,把对象的属性和方法结合成一个独立的整体,隐藏实现细节,并提供对外访问的接口。 -> -> 继承,从已知的一个类中派生出一个新的类,叫子类。子类实现了父类所有非私有化的属性和方法,并根据实际需求扩展出新的行为。 -> -> 多态,多个不同的对象对同一消息作出响应,同一消息根据不同的对象而采用各种不同的方法。 - -正是这三大特性,让我们的Java程序更加生动形象。 - -### 类的封装 - -封装的目的是为了保证变量的安全性,使用者不必在意具体实现细节,而只是通过外部接口即可访问类的成员,如果不进行封装,类中的实例变量可以直接查看和修改,可能给整个代码带来不好的影响,因此在编写类时一般将成员变量私有化,外部类需要使用Getter和Setter方法来查看和设置变量。 - -我们可以将之前的类进行改进: - -```java -public class Person { - private String name; //现在类的属性只能被自己直接访问 - private int age; - private String sex; - - public Person(String name, int age, String sex) { //构造方法也要声明为公共,否则对象都构造不了 - this.name = name; - this.age = age; - this.sex = sex; - } - - public String getName() { - return name; //想要知道这个对象的名字,必须通过getName()方法来获取,并且得到的只是名字值,外部无法修改 - } - - public String getSex() { - return sex; - } - - public int getAge() { - return age; - } -} -``` - -我们可以来试一下: - -```java -public static void main(String[] args) { - Person person = new Person("小明", 18, "男"); - System.out.println(person.getName()); //只能通过调用getName()方法来获取名字 -} -``` - -也就是说,外部现在只能通过调用我定义的方法来获取成员属性,而我们可以在这个方法中进行一些额外的操作,比如小明可以修改名字,但是名字中不能包含"小"这个字: - -```java -public void setName(String name) { - if(name.contains("小")) return; - this.name = name; -} -``` - -我们甚至还可以将构造方法改成私有的,需要通过我们的内部的方式来构造对象: - -```java -public class Person { - private String name; - private int age; - private String sex; - - private Person(){} //不允许外部使用new关键字创建对象 - - public static Person getInstance() { //而是需要使用我们的独特方法来生成对象并返回 - return new Person(); - } -} -``` - -通过这种方式,我们可以实现单例模式: - -> ```java -> public class Test { -> private static Test instance; -> -> private Test(){} -> -> public static Test getInstance() { -> if(instance == null) -> instance = new Test(); -> return instance; -> } -> } -> ``` -> -> 单例模式就是全局只能使用这一个对象,不能创建更多的对象,我们就可以封装成这样,关于单例模式的详细介绍,还请各位小伙伴在《Java设计模式》视频教程中再进行学习。 - -封装思想其实就是把实现细节给隐藏了,外部只需知道这个方法是什么作用,而无需关心实现,要用什么由类自己来做,不需要外面来操作类内部的东西去完成,封装就是通过访问权限控制来实现的。 - -### 类的继承 - -前面我们介绍了类的封装,我们接着来看一个非常重要特性:继承。 - -在定义不同类的时候存在一些相同属性,为了方便使用可以将这些共同属性抽象成一个父类,在定义其他子类时可以继承自该父类,减少代码的重复定义,子类可以使用父类中**非私有**的成员。 - -比如说我们一开始使用的人类,那么实际上人类根据职业划分,所掌握的技能也会不同,比如画家会画画,歌手会唱,舞者会跳,Rapper会rap,运动员会篮球,我们可以将人类这个大类根据职业进一步地细分出来: - -![image-20220921150139125](https://s2.loli.net/2022/09/21/zlZ9JXAjvxpawPF.png) - -实际上这些划分出来的类,本质上还是人类,也就是说人类具有的属性,这些划分出来的类同样具有,但是,这些划分出来的类同时也会拥有他们自己独特的技能。在Java中,我们可以创建一个类的子类来实现上面的这种效果: - -```java -public class Person { //先定义一个父类 - String name; - int age; - String sex; -} -``` - -接着我们可以创建各种各样的子类,想要继承一个类,我们只需要使用`extends`关键字即可: - -```java -public class Worker extends Person{ //工人类 - -} -``` - -```java -public class Student extends Person{ //学生类 - -} -``` - -类的继承可以不断向下,但是同时只能继承一个类,同时,标记为`final`的类不允许被继承: - -```java -public final class Person { //class前面添加final关键字表示这个类已经是最终形态,不能继承 - -} -``` - -当一个类继承另一个类时,属性会被继承,可以直接访问父类中定义的属性,除非父类中将属性的访问权限修改为`private`,那么子类将无法访问(但是依然是继承了这个属性的): - -```java -public class Student extends Person{ - public void study(){ - System.out.println("我的名字是 "+name+",我在学习!"); //可以直接访问父类中定义的name属性 - } -} -``` - -同样的,在父类中定义的方法同样会被子类继承: - -```java -public class Person { - String name; - int age; - String sex; - - public void hello(){ - System.out.println("我叫 "+name+",今年 "+age+" 岁了!"); - } -} -``` - -子类直接获得了此方法,当我们创建一个子类对象时就可以直接使用这个方法: - -```java -public static void main(String[] args) { - Student student = new Student(); - student.study(); //子类不仅有自己的独特技能 - student.hello(); //还继承了父类的全部技能 -} -``` - -是不是感觉非常人性化,子类继承了父类的全部能力,同时还可以扩展自己的独特能力,就像一句话说的: 龙生龙凤生凤,老鼠儿子会打洞。 - -如果父类存在一个有参构造方法,子类必须在构造方法中调用: - -```java -public class Person { - protected String name; //因为子类需要用这些属性,所以说我们就将这些变成protected,外部不允许访问 - protected int age; - protected String sex; - protected String profession; - - //构造方法也改成protected,只能子类用 - protected Person(String name, int age, String sex, String profession) { - this.name = name; - this.age = age; - this.sex = sex; - this.profession = profession; - } - - public void hello(){ - System.out.println("["+profession+"] 我叫 "+name+",今年 "+age+" 岁了!"); - } -} -``` - -可以看到,此时两个子类都报错了: - -![image-20220921153512798](https://s2.loli.net/2022/09/21/SgPjRtUN64bmWrX.png) - -因为子类在构造时,不仅要初始化子类的属性,还需要初始化父类的属性,所以说在默认情况下,子类其实是调用了父类的构造方法的,只是在无参的情况下可以省略,但是现在父类构造方法需要参数,那么我们就需要手动指定了: - -既然现在父类需要三个参数才能构造,那么子类需要按照同样的方式调用父类的构造方法: - -```java -public class Student extends Person{ - public Student(String name, int age, String sex) { //因为学生职业已经确定,所以说学生直接填写就可以了 - super(name, age, sex, "学生"); //使用super代表父类,父类的构造方法就是super() - } - - public void study(){ - System.out.println("我的名字是 "+name+",我在学习!"); - } -} -``` - -```java -public class Worker extends Person{ - public Worker(String name, int age, String sex) { - super(name, age, sex, "工人"); //父类构造调用必须在最前面 - System.out.println("工人构造成功!"); //注意,在调用父类构造方法之前,不允许执行任何代码,只能在之后执行 - } -} -``` - -我们在使用子类时,可以将其当做父类来使用: - -```java -public static void main(String[] args) { - Person person = new Student("小明", 18, "男"); //这里使用父类类型的变量,去引用一个子类对象(向上转型) - person.hello(); //父类对象的引用相当于当做父类来使用,只能访问父类对象的内容 -} -``` - -虽然我们这里使用的是父类类型引用的对象,但是这并不代表子类就彻底变成父类了,这里仅仅只是当做父类使用而已。 - -我们也可以使用强制类型转换,将一个被当做父类使用的子类对象,转换回子类: - -```java -public static void main(String[] args) { - Person person = new Student("小明", 18, "男"); - Student student = (Student) person; //使用强制类型转换(向下转型) - student.study(); -} -``` - -但是注意,这种方式只适用于这个对象本身就是对应的子类才可以,如果本身都不是这个子类,或者说就是父类,那么会出现问题: - -```java -public static void main(String[] args) { - Person person = new Worker("小明", 18, "男"); //实际创建的是Work类型的对象 - Student student = (Student) person; - student.study(); -} -``` - -![image-20220921160309835](https://s2.loli.net/2022/09/21/JdMLt19Yq6KQz4v.png) - -此时直接出现了类型转换异常,因为本身不是这个类型,强转也没用。 - -那么如果我们想要判断一下某个变量所引用的对象到底是什么类,那么该怎么办呢? - -```java -public static void main(String[] args) { - Person person = new Student("小明", 18, "男"); - if(person instanceof Student) { //我们可以使用instanceof关键字来对类型进行判断 - System.out.println("对象是 Student 类型的"); - } - if(person instanceof Person) { - System.out.println("对象是 Person 类型的"); - } -} -``` - -如果变量所引用的对象是对应类型或是对应类型的子类,那么`instanceof`都会返回`true`,否则返回`false`。 - -最后我们需要来特别说明一下,子类是可以定义和父类同名的属性的: - -```java -public class Worker extends Person{ - protected String name; //子类中同样可以定义name属性 - - public Worker(String name, int age, String sex) { - super(name, age, sex, "工人"); - } -} -``` - -此时父类的name属性和子类的name属性是同时存在的,那么当我们在子类中直接使用时: - -```java -public void work(){ - System.out.println("我是 "+name+",我在工作!"); //这里的name,依然是作用域最近的哪一个,也就是在当前子类中定义的name属性,而不是父类的name属性 -} -``` - -所以说,我们在使用时,实际上这里得到的结果为`null`: - -![image-20220921160742714](https://s2.loli.net/2022/09/21/nKDaTJZ2LhEX3Hs.png) - -那么,在子类存在同名变量的情况下,怎么去访问父类的呢?我们同样可以使用`super`关键字来表示父类: - -```java -public void work(){ - System.out.println("我是 "+super.name+",我在工作!"); //这里使用super.name来表示需要的是父类的name变量 -} -``` - -这样得到的结果就不一样了: - -![image-20220921160851193](https://s2.loli.net/2022/09/21/DobHL2CWRMIif3z.png) - -但是注意,没有`super.super`这种用法,也就是说如果存在多级继承的话,那么最多只能通过这种方法访问到父类的属性(包括继承下来的属性) - -### 顶层Object类 - -实际上所有类都默认继承自Object类,除非手动指定继承的类型,但是依然改变不了最顶层的父类是Object类。所有类都包含Object类中的方法,比如: - -![image-20220921214642969](https://s2.loli.net/2022/09/21/FCHDEI4rTAQquas.png) - -我们发现,除了我们自己在类中编写的方法之外,还可以调用一些其他的方法,那么这些方法不可能无缘无故地出现,肯定同样是因为继承得到的,那么这些方法是继承谁得到的呢? - -```java -public class Person extends Object{ -//除非我们手动指定要继承的类是什么,实际上默认情况下所有的类都是继承自Object的,只是可以省略 - -} -``` - -所以说我们的继承结构差不多就是: - -![image-20220921214944267](https://s2.loli.net/2022/09/21/hkapOYVHBrjy7UC.png) - -既然所有的类都默认继承自Object,我们来看看这个类里面有哪些内容: - -```java -public class Object { - - private static native void registerNatives(); //标记为native的方法是本地方法,底层是由C++实现的 - static { - registerNatives(); //这个类在初始化时会对类中其他本地方法进行注册,本地方法不是我们SE中需要学习的内容,我们会在JVM篇视频教程中进行介绍 - } - - //获取当前的类型Class对象,这个我们会在最后一章的反射中进行讲解,目前暂时不会用到 - public final native Class getClass(); - - //获取对象的哈希值,我们会在第五章集合类中使用到,目前各位小伙伴就暂时理解为会返回对象存放的内存地址 - public native int hashCode(); - - //判断当前对象和给定对象是否相等,默认实现是直接用等号判断,也就是直接判断是否为同一个对象 - public boolean equals(Object obj) { - return (this == obj); - } - - //克隆当前对象,可以将复制一个完全一样的对象出来,包括对象的各个属性 - protected native Object clone() throws CloneNotSupportedException; - - //将当前对象转换为String的形式,默认情况下格式为 完整类名@十六进制哈希值 - public String toString() { - return getClass().getName() + "@" + Integer.toHexString(hashCode()); - } - - //唤醒一个等待当前对象锁的线程,有关锁的内容,我们会在第六章多线程部分中讲解,目前暂时不会用到 - public final native void notify(); - - //唤醒所有等待当前对象锁的线程,同上 - public final native void notifyAll(); - - //使得持有当前对象锁的线程进入等待状态,同上 - public final native void wait(long timeout) throws InterruptedException; - - //同上 - public final void wait(long timeout, int nanos) throws InterruptedException { - ... - } - - //同上 - public final void wait() throws InterruptedException { - ... - } - - //当对象被判定为已经不再使用的“垃圾”时,在回收之前,会由JVM来调用一次此方法进行资源释放之类的操作,这同样不是SE中需要学习的内容,这个方法我们会在JVM篇视频教程中详细介绍,目前暂时不会用到 - protected void finalize() throws Throwable { } -} -``` - -这里我们可以尝试调用一下Object为我们提供的`toString()`方法: - -```java -public static void main(String[] args) { - Person person = new Student("小明", 18, "男"); - String str = person.toString(); - System.out.println(str); -} -``` - -这里就是按照上面说的格式进行打印: - -![image-20220921221053801](https://s2.loli.net/2022/09/21/hpBOjqf4iwJW1Pr.png) - -当然,我们直接可以给`println`传入一个Object类型的对象: - -```java -public void println(Object x) { - String s = String.valueOf(x); //这里同样会调用对象的toString方法,所以说跟上面效果是一样的 - synchronized (this) { - print(s); - newLine(); - } -} -``` - -有小伙伴肯定会好奇,这里不是接受的一个Object类型的值的,为什么任意类型都可以传入呢?因为所有类型都是继承自Object,如果方法接受的参数是一个引用类型的值,那只要是这个类的对象或是这个类的子类的对象,都可以作为参数传入。 - -我们也可以试试看默认提供的`equals`方法: - -```java -public static void main(String[] args) { - Person p1 = new Student("小明", 18, "男"); - Person p2 = new Student("小明", 18, "男"); - System.out.println(p1.equals(p2)); -} -``` - -因为默认比较的是两个对象是否为同一个对象,所以说这里得到的肯定是false,但是有些情况下,实际上我们所希望的情况是如果名字、年龄、性别都完全相同,那么这肯定是同一个人,但是这里却做不到这样的判断,我们需要修改一下`equals`方法的默认实现来完成,这就要用到方法的重写了。 - -### 方法的重写 - -注意,方法的重写不同于之前的方法重载,不要搞混了,方法的重载是为某个方法提供更多种类,而方法的重写是覆盖原有的方法实现,比如我们现在不希望使用Object类中提供的`equals`方法,那么我们就可以将其重写了: - -```java -public class Person{ - ... - - @Override //重写方法可以添加 @Override 注解,有关注解我们会在最后一章进行介绍,这个注解默认情况下可以省略 - public boolean equals(Object obj) { //重写方法要求与父类的定义完全一致 - if(obj == null) return false; //如果传入的对象为null,那肯定不相等 - if(obj instanceof Person) { //只有是当前类型的对象,才能进行比较,要是都不是这个类型还比什么 - Person person = (Person) obj; //先转换为当前类型,接着我们对三个属性挨个进行比较 - return this.name.equals(person.name) && //字符串内容的比较,不能使用==,必须使用equals方法 - this.age == person.age && //基本类型的比较跟之前一样,直接== - this.sex.equals(person.sex); - } - return false; - } -} -``` - -在重写Object提供的`equals`方法之后,就会按照我们的方式进行判断了: - -```java -public static void main(String[] args) { - Person p1 = new Student("小明", 18, "男"); - Person p2 = new Student("小明", 18, "男"); - System.out.println(p1.equals(p2)); //此时由于三个属性完全一致,所以说判断结果为真,即使是两个不同的对象 -} -``` - -有时候为了方便查看对象的各个属性,我们可以将Object类提供的`toString`方法重写了: - -```java -@Override -public String toString() { //使用IDEA可以快速生成 - return "Person{" + - "name='" + name + '\'' + - ", age=" + age + - ", sex='" + sex + '\'' + - ", profession='" + profession + '\'' + - '}'; -} -``` - -这样,我们直接打印对象时,就会打印出对象的各个属性值了: - -```java -public static void main(String[] args) { - Person person = new Student("小明", 18, "男"); - System.out.println(person); -} -``` - -![image-20220921223249343](https://s2.loli.net/2022/09/21/FCAnxSUjhaLuXW8.png) - -注意,静态方法不支持重写,因为它是属于类本身的,但是它可以被继承。 - -基于这种方法可以重写的特性,对于一个类定义的行为,不同的子类可以出现不同的行为,比如考试,学生考试可以得到A,而工人去考试只能得到D: - -```java -public class Person { - ... - - public void exam(){ - System.out.println("我是考试方法"); - } - - ... -} -``` - -```java -public class Student extends Person{ - ... - - @Override - public void exam() { - System.out.println("我是学生,我就是小镇做题家,拿个 A 轻轻松松"); - } -} -``` - -```java -public class Worker extends Person{ - ... - - @Override - public void exam() { - System.out.println("我是工人,做题我并不擅长,只能得到 D"); - } -} -``` - -这样,不同的子类,对于同一个方法会产生不同的结果: - -```java -public static void main(String[] args) { - Person person = new Student("小明", 18, "男"); - person.exam(); - - person = new Worker("小强", 18, "男"); - person.exam(); -} -``` - -![image-20220921224525855](https://s2.loli.net/2022/09/21/zogT67B91tJaHLD.png) - -这其实就是面向对象编程中多态特性的一种体现。 - -注意,我们如果不希望子类重写某个方法,我们可以在方法前添加`final`关键字,表示这个方法已经是最终形态: - -```java -public final void exam(){ - System.out.println("我是考试方法"); -} -``` - -![image-20220921224907373](https://s2.loli.net/2022/09/21/zpKfDlGTLwx5iy8.png) - -或者,如果父类中方法的可见性为`private`,那么子类同样无法访问,也就不能重写,但是可以定义同名方法: - -![image-20220921225651487](https://s2.loli.net/2022/09/21/d9k21hyGL6WExZ3.png) - -虽然这里可以编译通过,但是并不是对父类方法的重写,仅仅是子类自己创建的一个新方法。 - -还有,我们在重写父类方法时,如果希望调用父类原本的方法实现,那么同样可以使用`super`关键字: - -```java -@Override -public void exam() { - super.exam(); //调用父类的实现 - System.out.println("我是工人,做题我并不擅长,只能得到 D"); -} -``` - -然后就是访问权限的问题,子类在重写父类方法时,不能降低父类方法中的可见性: - -```java -public void exam(){ - System.out.println("我是考试方法"); -} -``` - -![image-20220921225234226](https://s2.loli.net/2022/09/21/zfhZ3YdFeCgJu89.png) - -因为子类实际上可以当做父类使用,如果子类的访问权限比父类还低,那么在被当做父类使用时,就可能出现无视访问权限调用的情况,这样肯定是不行的,但是相反的,我们可以在子类中提升权限: - -```java -protected void exam(){ - System.out.println("我是考试方法"); -} -``` - -```java -@Override -public void exam() { //将可见性提升为public - System.out.println("我是工人,做题我并不擅长,只能得到 D"); -} -``` - -![image-20220921225840122](https://s2.loli.net/2022/09/21/igvGNTQs2xKOZrI.png) - -可以看到作为子类时就可以正常调用,但是如果将其作为父类使用,因为访问权限不足所有就无法使用,总之,子类重写的方法权限不能比父类还低。 - -### 抽象类 - -在我们学习了类的继承之后,实际上我们会发现,越是处于顶层定义的类,实际上可以进一步地进行抽象,比如我们前面编写的考试方法: - -```java -protected void exam(){ - System.out.println("我是考试方法"); -} -``` - -这个方法再子类中一定会被重写,所以说除非子类中调用父类的实现,否则一般情况下永远都不会被调用,就像我们说一个人会不会考试一样,实际上人怎么考试是一个抽象的概念,而学生怎么考试和工人怎么考试,才是具体的一个实现,所以说,我们可以将人类进行进一步的抽象,让某些方法完全由子类来实现,父类中不需要提供实现。 - -要实现这样的操作,我们可以将人类变成抽象类,抽象类比类还要抽象: - -```java -public abstract class Person { //通过添加abstract关键字,表示这个类是一个抽象类 - protected String name; //大体内容其实普通类差不多 - protected int age; - protected String sex; - protected String profession; - - protected Person(String name, int age, String sex, String profession) { - this.name = name; - this.age = age; - this.sex = sex; - this.profession = profession; - } - - public abstract void exam(); //抽象类中可以具有抽象方法,也就是说这个方法只有定义,没有方法体 -} -``` - -而具体的实现,需要由子类来完成,而且如果是子类,必须要实现抽象类中所有抽象方法: - -```java -public class Worker extends Person{ - - public Worker(String name, int age, String sex) { - super(name, age, sex, "工人"); - } - - @Override - public void exam() { //子类必须要实现抽象类所有的抽象方法,这是强制要求的,否则会无法通过编译 - System.out.println("我是工人,做题我并不擅长,只能得到 D"); - } -} -``` - -抽象类由于不是具体的类定义(它是类的抽象)可能会存在某些方法没有实现,因此无法直接通过new关键字来直接创建对象: - -![image-20220921231744420](https://s2.loli.net/2022/09/21/GLQU8hANw36P5J7.png) - -要使用抽象类,我们只能去创建它的子类对象。 - -抽象类一般只用作继承使用,当然,抽象类的子类也可以是一个抽象类: - -```java -public abstract class Student extends Person{ //如果抽象类的子类也是抽象类,那么可以不用实现父类中的抽象方法 - public Student(String name, int age, String sex) { - super(name, age, sex, "学生"); - } - - @Override //抽象类中并不是只能有抽象方法,抽象类中也可以有正常方法的实现 - public void exam() { - System.out.println("我是学生,我就是小镇做题家,拿个 A 轻轻松松"); - } -} -``` - -注意,抽象方法的访问权限不能为`private`: - -![image-20220921232435056](https://s2.loli.net/2022/09/21/1ZJSRU2Aj5K9Ikv.png) - -因为抽象方法一定要由子类实现,如果子类都访问不了,那么还有什么意义呢?所以说不能为私有。 - -### 接口 - -接口甚至比抽象类还抽象,他只代表某个确切的功能!也就是只包含方法的定义,甚至都不是一个类!接口一般只代表某些功能的抽象,接口包含了一些列方法的定义,类可以实现这个接口,表示类支持接口代表的功能(类似于一个插件,只能作为一个附属功能加在主体上,同时具体实现还需要由主体来实现) - -咋一看,这啥意思啊,什么叫支持接口代表的功能?实际上接口的目标就是将类所具有某些的行为抽象出来。 - -比如说,对于人类的不同子类,学生和老师来说,他们都具有学习这个能力,既然都有,那么我们就可以将学习这个能力,抽象成接口来进行使用,只要是实现这个接口的类,都有学习的能力: - -```java -public interface Study { //使用interface表示这是一个接口 - void study(); //接口中只能定义访问权限为public抽象方法,其中public和abstract关键字可以省略 -} -``` - -我们可以让类实现这个接口: - -```java -public class Student extends Person implements Study { //使用implements关键字来实现接口 - public Student(String name, int age, String sex) { - super(name, age, sex, "学生"); - } - - @Override - public void study() { //实现接口时,同样需要将接口中所有的抽象方法全部实现 - System.out.println("我会学习!"); - } -} -``` - -```java -public class Teacher extends Person implements Study { - protected Teacher(String name, int age, String sex) { - super(name, age, sex, "教师"); - } - - @Override - public void study() { - System.out.println("我会加倍学习!"); - } -} -``` - -接口不同于继承,接口可以同时实现多个: - -```java -public class Student extends Person implements Study, A, B, C { //多个接口的实现使用逗号隔开 - -} -``` - -所以说有些人说接口其实就是Java中的多继承,但是我个人认为这种说法是错的,实际上实现接口更像是一个类的功能列表,作为附加功能存在,一个类可以附加很多个功能,接口的使用和继承的概念有一定的出入,顶多说是多继承的一种替代方案。 - -接口跟抽象类一样,不能直接创建对象,但是我们也可以将接口实现类的对象以接口的形式去使用: - -![image-20220921234735828](https://s2.loli.net/2022/09/21/VJfhzYKuF38tRq4.png) - -当做接口使用时,只有接口中定义的方法和Object类的方法,无法使用类本身的方法和父类的方法。 - -接口同样支持向下转型: - -```java -public static void main(String[] args) { - Study study = new Teacher("小王", 27, "男"); - if(study instanceof Teacher) { //直接判断引用的对象是不是Teacher类型 - Teacher teacher = (Teacher) study; //强制类型转换 - teacher.study(); - } -} -``` - -这里的使用其实跟之前的父类是差不多的。 - -从Java8开始,接口中可以存在方法的默认实现: - -```java -public interface Study { - void study(); - - default void test() { //使用default关键字为接口中的方法添加默认实现 - System.out.println("我是默认实现"); - } -} -``` - -如果方法在接口中存在默认实现,那么实现类中不强制要求进行实现。 - -接口不同于类,接口中不允许存在成员变量和成员方法,但是可以存在静态变量和静态方法,在接口中定义的变量只能是: - -```java -public interface Study { - public static final int a = 10; //接口中定义的静态变量只能是public static final的 - - public static void test(){ //接口中定义的静态方法也只能是public的 - System.out.println("我是静态方法"); - } - - void study(); -} -``` - -跟普通的类一样,我们可以直接通过接口名.的方式使用静态内容: - -```java -public static void main(String[] args) { - System.out.println(Study.a); - Study.test(); -} -``` - -接口是可以继承自其他接口的: - -```java -public interface A exetnds B { - -} -``` - -并且接口没有继承数量限制,接口支持多继承: - -```java -public interface A exetnds B, C, D { - -} -``` - -接口的继承相当于是对接口功能的融合罢了。 - -最后我们来介绍一下Object类中提供的克隆方法,为啥要留到这里才来讲呢?因为它需要实现接口才可以使用: - -```java -package java.lang; - -public interface Cloneable { //这个接口中什么都没定义 -} -``` - -实现接口后,我们还需要将克隆方法的可见性提升一下,不然还用不了: - -```java -public class Student extends Person implements Study, Cloneable { //首先实现Cloneable接口,表示这个类具有克隆的功能 - public Student(String name, int age, String sex) { - super(name, age, sex, "学生"); - } - - @Override - public Object clone() throws CloneNotSupportedException { //提升clone方法的访问权限 - return super.clone(); //因为底层是C++实现,我们直接调用父类的实现就可以了 - } - - @Override - public void study() { - System.out.println("我会学习!"); - } -} -``` - -接着我们来尝试一下,看看是不是会得到一个一模一样的对象: - -```java -public static void main(String[] args) throws CloneNotSupportedException { //这里向上抛出一下异常,还没学异常,所以说照着写就行了 - Student student = new Student("小明", 18, "男"); - Student clone = (Student) student.clone(); //调用clone方法,得到一个克隆的对象 - System.out.println(student); - System.out.println(clone); - System.out.println(student == clone); -} -``` - -可以发现,原对象和克隆对象,是两个不同的对象,但是他们的各种属性都是完全一样的: - -![image-20220922110044636](https://s2.loli.net/2022/09/22/E3dNFYT5sWaS8Rx.png) - -通过实现接口,我们就可以很轻松地完成对象的克隆了,在我们之后的学习中,还会经常遇到接口的使用。 - -**注意:**以下内容为选学内容,在设计模式篇视频教程中有详细介绍。 - -> 克隆操作可以完全复制一个对象的所有属性,但是像这样的拷贝操作其实也分为浅拷贝和深拷贝。 -> -> * **浅拷贝:**对于类中基本数据类型,会直接复制值给拷贝对象;对于引用类型,只会复制对象的地址,而实际上指向的还是原来的那个对象,拷贝个基莫。 -> * **深拷贝:**无论是基本类型还是引用类型,深拷贝会将引用类型的所有内容,全部拷贝为一个新的对象,包括对象内部的所有成员变量,也会进行拷贝。 -> -> 那么clone方法出来的克隆对象,是深拷贝的结果还是浅拷贝的结果呢? -> -> ```java -> public static void main(String[] args) throws CloneNotSupportedException { -> Student student = new Student("小明", 18, "男"); -> Student clone = (Student) student.clone(); -> System.out.println(student.name == clone.name); -> } -> ``` -> -> ![image-20220922110750697](https://s2.loli.net/2022/09/22/gpM1iukyoSdn2RE.png) -> -> 可以看到,虽然Student对象成功拷贝,但是其内层对象并没有进行拷贝,依然只是对象引用的复制,所以Java为我们提供的`clone`方法只会进行浅拷贝。 - -*** - -## 枚举类 - -假设现在我们想给小明添加一个状态(跑步、学习、睡觉),外部可以实时获取小明的状态: - -```java -public class Student extends Person implements Study { - - private String status; //状态,可以是跑步、学习、睡觉这三个之中的其中一种 - - public String getStatus() { - return status; - } - - public void setStatus(String status) { - this.status = status; - } -} -``` - -但是这样会出现一个问题,如果我们仅仅是存储字符串,似乎外部可以不按照我们规则,传入一些其他的字符串。这显然是不够严谨的,有没有一种办法,能够更好地去实现这样的状态标记呢?我们希望开发者拿到使用的就是我们预先定义好的状态,所以,我们可以使用枚举类来完成: - -```java -public enum Status { //enum表示这是一个枚举类,枚举类的语法稍微有一些不一样 - RUNNING, STUDY, SLEEP; //直接写每个状态的名字即可,最后面分号可以不打,但是推荐打上 -} -``` - -使用枚举类也非常方便,就像使用普通类型那样: - -```java -private Status status; //类型变成刚刚定义的枚举类 - -public Status getStatus() { - return status; -} - -public void setStatus(Status status) { - this.status = status; -} -``` - -这样,别人在使用时,就能很清楚地知道我们支持哪些了: - -![image-20220922111426974](https://s2.loli.net/2022/09/22/6SDXckyIfFoCZWg.png) - -枚举类型使用起来就非常方便了,其实枚举类型的本质就是一个普通的类,但是它继承自`Enum`类,我们定义的每一个状态其实就是一个`public static final`的Status类型成员变量: - -```java -//这里使用javap命令对class文件进行反编译得到 Compiled from "Status.java" -public final class com.test.Status extends java.lang.Enum { - public static final com.test.Status RUNNING; - public static final com.test.Status STUDY; - public static final com.test.Status SLEEP; - public static com.test.Status[] values(); - public static com.test.Status valueOf(java.lang.String); - static {}; -} -``` - -既然枚举类型是普通的类,那么我们也可以给枚举类型添加独有的成员方法: - -```java -public enum Status { - RUNNING("睡觉"), STUDY("学习"), SLEEP("睡觉"); //无参构造方法被覆盖,创建枚举需要添加参数(本质就是调用的构造方法) - - private final String name; //枚举的成员变量 - Status(String name){ //覆盖原有构造方法(默认private,只能内部使用!) - this.name = name; - } - - public String getName() { //获取封装的成员变量 - return name; - } -} -``` - -这样,枚举就可以按照我们想要的中文名称打印了: - -```java -public static void main(String[] args) { - Student student = new Student("小明", 18, "男"); - student.setStatus(Status.RUNNING); - System.out.println(student.getStatus().getName()); -} -``` - -枚举类还自带一些继承下来的实用方法,比如获取枚举类中的所有枚举,只不过这里用到了数组,我们会在下一章进行介绍。 - -至此,面向对象基础内容就全部讲解完成了,下一章我们还将继续讲解面向对象的其他内容。 diff --git a/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(二)重制版.md b/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(二)重制版.md deleted file mode 100644 index 0c7f2fe..0000000 --- a/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(二)重制版.md +++ /dev/null @@ -1,1948 +0,0 @@ -![image-20220916174714019](https://s2.loli.net/2022/09/16/ymtrNQlPu9Loh27.png) - -# 面向过程篇 - -前面我们已经认识了Java语言的相关特性,并且已经成功配置好了开发环境,从这节课开始,我们就可以正式进入到Java语言的学习当中了。Java语言是一门面向对象的语言,但是在面向对象之前,我们还得先学会如何面向过程编程。 - -## Java程序基础 - -首先我们还是从最基本的Java程序基础开始讲解。 - -### 程序代码基本结构 - -还记得我们之前使用的示例代码吗? - -```java -public class Main { - public static void main(String[] args) { - System.out.println("Hello World!"); - } -} -``` - -这段代码要实现的功能很简单,就是将 Hello World 输出到控制台就行。 - -由于我们还没有学习到类的相关性质,所以在第二章之前,各位小伙伴直接记住固定模式即可,首先我们创建的源文件名称需要为`Main.java`然后编写的代码第一行: - -```java -public class Main { - -} -``` - -注意需要区分大小写,Java语言严格区分大小写,如果我们没有按照规则来编写,那么就会出现红色波浪线报错: - -![image-20220916213529426](https://s2.loli.net/2022/09/16/5mpBD1JyjCMGgnO.png) - -只要源代码中存在报错的地方,就无法正常完成编译得到二进制文件,会提示构建失败: - -![image-20220916213641899](https://s2.loli.net/2022/09/16/x5PjR9OAGMCQtS6.png) - -注意最后还有一个花括号,并且此花括号是成对出现的,一一对应。 - -所以说各位小伙伴在编写代码时一定要注意大小写。然后第二行,准确的说是最外层花括号内部就是: - -```java -public static void main(String[] args) { - -} -``` - -这是我们整个Java程序的入口点,我们称为主方法(如果你学习过C肯定能够联想到主函数,只不过Java中不叫函数,叫方法)最后也会有一个花括号成对出现,而在主方法的花括号中编写的代码,就是按照从上往下的顺序依次执行的。 - -比如我们之前编写的: - -```java -System.out.println("Hello World!"); -``` - -这段代码的意思就是将双引号括起来的内容(字符串,我们会在后面进行讲解)输出(打印)到控制台上,可以看到最后还加上了一个`;`符号,表示这一句代码结束。我们每一段代码结束时都需要加上一个分号表示这一句的结束,就像我们写作文一样。 - -比如下面的代码,我们就可以实现先打印Hello World!,然后再打印YYDS!到控制台。 - -```java -public class Main { - public static void main(String[] args) { - System.out.println("Hello World!"); - System.out.println("YYDS!"); - } -} -``` - -效果如下: - -![image-20220916214557378](https://s2.loli.net/2022/09/16/GLZdxf6B3Agu98N.png) - -如果两段代码没有加上分号分割,那么编译器会认为这两段代码是同一句代码中的,即使出现换行或者是空格: - -![image-20220916214736541](https://s2.loli.net/2022/09/16/ErQnpo2DVw7mJks.png) - -这里IDEA很聪明,会提醒我们这里少加了分号,所以说这个IDEA能够在初期尽可能地帮助新手。 - -再比如下面的代码: - -![image-20220916214822072](https://s2.loli.net/2022/09/16/sDcuan8MJ92l3P1.png) - -![image-20220916214929651](https://s2.loli.net/2022/09/16/i1VFk6RUtp8XfMr.png) - -这里我们尝试在中途换行和添加空格,因为没有添加分号,所以说编译器依然会认为是一行代码,因此编译不会出现错误,能够正常通过。当然,为了代码写得工整和规范,我们一般不会随意进行换行编写或者是添加没必要的空格。 - -同样的,如果添加了分号,即使在同一行,也会被认为是两句代码: - -![image-20220916221833145](https://s2.loli.net/2022/09/16/XopC59keJiMWjmd.png) - -如果在同一行就是从左往右的顺序,得到的结果跟上面是一样的。 - -### 注释 - -我们在编写代码时,可能有些时候需要标记一下这段代码表示什么意思: - -![image-20220916221711430](https://s2.loli.net/2022/09/16/8Mzo36BbYVuRgm9.png) - -但是如果直接写上文字的话,会导致编译不通过,因为这段文字也会被认为是程序的一部分。 - -这种情况,我们就可以告诉编译器,这段文字是我们做的笔记,并不是程序的一部分,那么要怎么告诉编译器这不是代码呢?很简单,我们只需要在前面加上双斜杠就可以了: - -![image-20220916222035778](https://s2.loli.net/2022/09/16/N4rZHt6onGfXuhg.png) - -添加双斜杠之后(自动变成了灰色),后续的文本内容只要没有发生换行,那么都会被认为是一段注释,并不属于程序,在编译时会被直接忽略,之后这段注释也不会存在于程序中。但是一旦发生换行那就不行了: - -![image-20220916222225047](https://s2.loli.net/2022/09/16/GiUMCmXewanWJSN.png) - -那要是此时注释很多,一行写不完,我们想要编写很多行的注释呢?我们可以使用多行注释标记: - -```java -public class Main { - public static void main(String[] args) { - /* - 这里面的内容 - 无论多少行 - 都可以 - */ - System.out.println("Hello World!"); - } -} -``` - -多行可以使用`/*`和`*/`的组合来囊括需要编写的注释内容。 - -当然还有一种方式就是使用`/**`来进行更加详细的文档注释: - -![image-20220916222636943](https://s2.loli.net/2022/09/16/sFhkS2ezONjZvMK.png) - -这种注释可以用来自动生成文档,当我们鼠标移动到Main上时,会显示相关的信息,我们可以自由添加一些特殊的注释,比如作者、时间等信息,也可以是普通的文字信息。 - -### 变量与常量 - -我们的程序不可能永远都只进行上面那样的简单打印操作,有些时候可能需要计算某些数据,此时我们就需要用到变量了。 - -那么,什么是变量呢?我们在数学中其实已经学习过变量了: - -> 变量,指值可以变的量。变量以非[数字](https://baike.baidu.com/item/数字/6204?fromModule=lemma_inlink)的符号来表达,一般用拉丁字母。变量的用处在于能一般化描述指令的方式。结果只能使用真实的值,指令只能应用于某些情况下。变量能够作为某特定种类的值中任何一个的保留器。 - -比如一个公式 $x^2 + 6 = 22$ 此时`x`就是一个变量,变量往往代表着某个值,比如这里的`x`就代表的是4这个值。在Java中,我们也可以让变量去代表一个具体的值,并且变量的值是可以发生变化的。 - -要声明一个变量,我们需要使用以下格式: - -```java -[数据类型] [变量名称]; -``` - -这里的数据类型我们会在下节课开始逐步讲解,比如整数就是`int`类型,不同类型的变量可以存储不同的类型的值。后面的变量名称顾名思义,就像`x`一样,这个名称我们可以随便起一个,但是注意要满足以下要求: - -- 标识符可以由大小写字母、数字、下划线(_)和美元符号($)组成,但是不能以数字开头。 -- 变量不能重复定义,大小写敏感,比如A和a就是两个不同的变量。 -- 不能有空格、@、#、+、-、/ 等符号。 -- 应该使用有意义的名称,达到见名知意的目的(一般我们采用英文单词),最好以小写字母开头。 -- 不可以是 true 和 false。 -- 不能与Java语言的关键字或是基本数据类型重名,关键字列表如下: - -![image-20220916224014438](/Users/nagocoler/Library/Application Support/typora-user-images/image-20220916224014438.png) - -当然各位小伙伴没必要刻意去进行记忆,我们会在学习的过程中逐步认识到这些关键字。新手要辨别一个单词是否为关键字,只需要通过IDEA的高亮颜色进行区分即可,比如: - -![image-20220916224129597](https://s2.loli.net/2022/09/16/qtsjIhSGQoxBYVM.png) - -深色模式下,关键字会高亮为橙色,浅色模式下会高亮为深蓝色,普通的代码都是正常的灰白色。 - - 比如现在我们想要定义一个变量`a`,那么就可以这样编写: - -```java -public class Main { - public static void main(String[] args) { - int a; //声明一个整数类型变量a - } -} -``` - -但是这个变量一开始没有任何值,比如现在我们要让这个变量表示10,那么就可以将10赋值给这个变量: - -```java -public static void main(String[] args) { - int a = 10; //直接在定义变量后面添加 = 10,表示这个变量的初始值为10,这里的10就是一个常量数字 -} -``` - -或者我们可以在使用时再对其进行赋值: - -```java -public static void main(String[] args) { - int a; - a = 10; //使用时再赋值也可以 -} -``` - -是不是感觉跟数学差不多?这种写法对于我们人来说,实际上是很好理解的,意思表达很清晰。 - -我们可以一次性定义多个变量,比如现在我们想定义两个`int`类型的变量: - -```java -public static void main(String[] args) { - int a, b; //定义变量a和变量b,中间使用逗号隔开就行了 -} -``` - -或者两个变量单独声明也可以: - -```java -public static void main(String[] args) { - int a; //分两句进行声明 - int b; -} -``` - -为了更直观地查看变量的值,我们可以直接将变量的值也给打印到控制台: - -```java -public static void main(String[] args) { - int a = 666; - System.out.println(a); //之前我们在小括号写的是"",现在我们直接将变量给进去就可以打印变量的值了 - System.out.println(888); //甚至直接输出一个常量值都可以 -} -``` - -得到结果: - -![image-20220916225037221](https://s2.loli.net/2022/09/16/3nUAHINdXMmlxvJ.png) - -变量的值也可以在中途进行修改: - -```java -public static void main(String[] args) { - int a = 666; - a = 777; - System.out.println(a); //这里打印得到的值就是777了 -} -``` - -变量的值也可以直接指定为其他变量的值: - -```java -public static void main(String[] args) { - int a = 10; - int b = a; //直接让b等于a,那么a的值就会给到b - System.out.println(b); //这里输出的就是10了 -} -``` - -我们还可以让变量与数值之间做加减法(运算符会在后面详细介绍): - -```java -public static void main(String[] args) { - int a = 9; //a初始值为9 - a = a + 1; //a = a + 1也就是将a+1的结果赋值给a,跟数学是一样的,很好理解对吧 - System.out.println(a); //最后得到的结果就是10了 -} -``` - -有时候我们希望变量的值一直保持不变,我们就可以将其指定为常量,这里我们介绍Java中第一个需要认识的关键字: - -```java -public static void main(String[] args) { - final int a = 666; //在变量前面添加final关键字,表示这是一个常量 - a = 777; //常量的值不允许发生修改 -} -``` - -编译时出现: - -![image-20220916225429474](https://s2.loli.net/2022/09/16/kT46yi8KNOLWlp3.png) - -常量的值只有第一次赋值可以修改,其他任何情况下都不行: - -```java -public static void main(String[] args) { - final int a; - a = 777; //第一次赋值 -} -``` - -至此,Java的基础语法部分介绍完毕,下一部分我们将开始介绍Java中的几大基本数据类型。 - -*** - -## 基本数据类型 - -我们的程序中可能需要表示各种各样的数据,比如整数、小数、字符等等,这一部分我们将探索Java中的八大基本数据类型。只不过在开始之前,我们还需要先补充一点简单的计算机小知识。 - -### 计算机中的二进制表示 - -在计算机中,所有的内容都是二进制形式表示。十进制是以10为进位,如9+1=10;二进制则是满2进位(因为我们的计算机是电子的,电平信号只有高位和低位,你也可以暂且理解为通电和不通电,高电平代表1,低电平代表0,由于只有0和1,因此只能使用2进制表示我们的数字!)比如1+1=10=2^1+0,一个位也叫一个bit,8个bit称为1字节,16个bit称为一个字,32个bit称为一个双字,64个bit称为一个四字,我们一般采用字节来描述数据大小。 - -注意这里的bit跟我们生活中的网速MB/s是不一样的,小b代表的是bit,大B代表的是Byte字节(8bit = 1Byte字节),所以说我们办理宽带的时候,100Mbps这里的b是小写的,所以说实际的网速就是100/8 = 12.5 MB/s了。 - -> 十进制的7 -> 在二进制中为 111 = 2^2 + 2^1 + 2^0 - -现在有4个bit位,最大能够表示多大的数字呢? - -- 最小:0000 => 0 -- 最大:1111 => 23+22+21+20 => 8 + 4 + 2 + 1 = 15 - -在Java中,无论是小数还是整数,他们都要带有符号(和C语言不同,C语言有无符号数)所以,首位就作为我们的符号位,还是以4个bit为例,首位现在作为符号位(1代表负数,0代表正数): - -- 最小:1111 => -(22+21+2^0) => -7 -- 最大:0111 => +(22+21+2^0) => +7 => 7 - -现在,我们4bit能够表示的范围变为了-7~+7,这样的表示方式称为**原码**。虽然原码表示简单,但是原码在做加减法的时候,很麻烦!以4bit位为例: - -> 1+(-1) = 0001 + 1001 = 怎么让计算机去计算?(虽然我们知道该去怎么算,但是计算机不知道!) - -我们得创造一种更好的表示方式!于是我们引入了**反码**: - -- 正数的反码是其本身 -- 负数的反码是在其原码的基础上, 符号位不变,其余各个位取反 - -经过上面的定义,我们再来进行加减法: - -> 1+(-1) = 0001 + 1110 = 1111 => -0 (直接相加,这样就简单多了!) - -思考:1111代表-0,0000代表+0,在我们实数的范围内,0有正负之分吗?0既不是正数也不是负数,那么显然这样的表示依然不够合理!根据上面的问题,我们引入了最终的解决方案,那就是**补码**,定义如下: - -- 正数的补码就是其本身 (不变!) -- 负数的补码是在其原码的基础上, 符号位不变, 其余各位取反, 最后+1(即在反码的基础上+1,此时1000表示-8) -- 对补码再求一次补码就可得该补码对应的原码。 - -比如-7原码为1111,反码为1000,补码就是1001了,-6原码为1110,反码为1001,补码就是1010。所以在补码下,原本的1000就作为新增的最小值-8存在。 - -所以现在就已经能够想通,-0已经被消除了!我们再来看上面的运算: - -> 1+(-1) = 0001 + 1111 = (1)0000 => +0 (现在无论你怎么算,也不会有-0了!) - -所以现在,1111代表的不再是-0,而是-1,相应的,由于消除-0,负数多出来一个可以表示的数(1000拿去表示-8了),那么此时4bit位能够表示的范围是:-8~+7(Java使用的就是补码!)在了解了计算机底层的数据表示形式之后,我们再来学习这些基本数据类型就会很轻松了。 - -### 整数类形 - -整数类型是最容易理解的类型!既然我们知道了计算机中的二进制数字是如何表示的,那么我们就可以很轻松的以二进制的形式来表达我们十进制的内容了。 - -在Java中,整数类型包括以下几个: - -- byte 字节型 (8个bit,也就是1个字节)范围:-128~+127 -- short 短整形(16个bit,也就是2个字节)范围:-32768~+32767 -- int 整形(32个bit,也就是4个字节)最常用的类型:-2147483648 ~ +2147483647 -- long 长整形(64个bit,也就是8个字节)范围:-9223372036854775808 ~ +9223372036854775807 - -这里我们来使用一下,其实这几种变量都可以正常表示整数: - -```java -public static void main(String[] args) { - short a = 10; - System.out.println(a); -} -``` - -因为都可以表示整数,所以说我们可以将小的整数类型值传递给大的整数类型: - -```java -public static void main(String[] args) { - short a = 10; - int b = a; //小的类型可以直接传递给表示范围更大的类型 - System.out.println(b); -} -``` - -反之会出现报错: - -![image-20220916231650085](https://s2.loli.net/2022/09/16/NLZlDgxz3ci5Idr.png) - -这是由于我们在将小的整数类型传递给大的整数类型时发生了**隐式类型转换**,只要是从存储范围小的类型到存储范围大的类型,都支持隐式类型转换,它可以自动将某种类型的值,转换为另一种类型,比如上面就是将short类型的值转换为了int类型的值。 - -隐式类型转换不仅可以发生在整数之间,也可以是其他基本数据类型之间,我们后面会逐步介绍。 - -实际上我们在为变量赋一个常量数值时,也发生了隐式类型转换,比如: - -```java -public static void main(String[] args) { - byte b = 10; //这里的整数常量10,实际上默认情况下是int类型,但是由于正好在对应类型可以表示的范围内,所以说直接转换为了byte类型的值 -} -``` - -由于直接编写的整数常量值默认为`int`,这里需要特别注意一下,比如下面这种情况: - -![image-20220916232420547](https://s2.loli.net/2022/09/16/76GgjWYz4DPBy1p.png) - -按照`long`类型的规定,实际上是可以表示这么大的数字的,但是为什么这里报错了呢?这是因为我们直接在代码中写的常量数字,默认情况下就是`int`类型,这么大肯定是表示不下的,如果需要将其表示为一个long类型的常量数字,那么需要在后面添加大写或是小写的`L`才可以。 - -```java -public static void main(String[] args) { - long a = 922337203685477580L; //这样就可以正常编译通过了 -} -``` - -当然,针对于这种很长的数字,为了提升辨识度,我们可以使用下划线分割每一位: - -```java -public static void main(String[] args) { - int a = 1_000_000; //当然这里依然表示的是1000000,没什么区别,但是辨识度会更高 -} -``` - -我们也可以以8进制或是16进制表示一个常量值: - -```java -public static void main(String[] args) { - System.out.println(0xA); - System.out.println(012); -} -``` - -* **十六进制:**以`0x`开头的都是十六进制表示法,十六进制满16进一,但是由于我们的数学只提供了0-9这十个数字,10、11、12...15该如何表示呢,我们使用英文字母A按照顺序开始表示,A表示10、B表示11...F表示15。比如上面的0xA实际上就是我们十进制中的10。 -* **八进制:**以0开头的都是八进制表示法,八进制就是满8进一,所以说只能使用0-7这几个数字,比如上面的012实际上就是十进制的10。 - -我们最后再来看一个问题: - -```java -public static void main(String[] args) { - int a = 2147483647; //int最大值 - a = a + 1; //继续加 - System.out.println(a); -} -``` - -此时a的值已经来到了`int`类型所能表示的最大值了,那么如果此时再继续`+1`,各位小伙伴觉得会发生什么?可以看到结果很奇怪: - -![image-20220916234540720](https://s2.loli.net/2022/09/16/YztefPIvLE6y94u.png) - -什么情况???怎么正数加1还变成负数了?请各位小伙伴回想一下我们之前讲解的原码、反码和补码。 - -我们先来看看,当int为最大值时,二进制表示形式为什么: - -* 2147483647 = 01111111 11111111 11111111 11111111(第一个是符号位0,其他的全部为1,就是正数的最大值) - -那么此时如果加1,会进位成: - -* 10000000 00000000 00000000 00000000 - -各位想一想,符号位为1,那么此时表示的不就是一个负数了吗?我们回想一下负数的补码表示规则,瞬间就能明白了,这不就是补码形式下的最小值了吗? - -所以说最后的结果就是`int`类型的最小值:-2147483648,是不是感觉了解底层原理会更容易理解这是为什么。 - -### 浮点类型 - -前面我们介绍了整数类型,我们接着来看看浮点类型,在Java中也可以轻松地使用小数。 - -首先来看看Java中的小数类型包含哪些: - -- float 单精度浮点型 (32bit,4字节) -- double 双精度浮点型(64bit,8字节) - -那么小数在计算机中又是如何存放的呢? - -![image-20220917102209246](https://s2.loli.net/2022/09/17/CpI5jaWgR9nqTbc.png) - -根据国际标准 IEEE 754,任意一个二进制浮点数 V 可以表示成下面的形式: -$$ -V = (-1)^S \times M \times 2^E -$$ - -* $(-1)^S$ 表示符号位,当 S=0,V 为正数;当 S=1,V 为负数。 -* M 表示有效数字,大于等于 1,小于 2,但整数部分的 1 不变,因此可以省略。(例如尾数为1111010,那么M实际上就是1.111010,尾数首位必须是1,1后面紧跟小数点,如果出现0001111这样的情况,去掉前面的0,移动1到首位;题外话:随着时间的发展,IEEE 754标准默认第一位为1,故为了能够存放更多数据,就舍去了第一位,比如保存1.0101 的时候, 只保存 0101,这样能够多存储一位数据) -* $2^E$ 表示指数位。(用于移动小数点,所以说才称为浮点型) - -比如, 对于十进制的 5.25 对应的二进制为:101.01,相当于:$1.0101 \times 2^2$。所以,S 为 0,M 为 1.0101,E 为 2。因此,对于浮点类型,最大值和最小值不仅取决于符号和尾数,还有它的阶码,所以浮点类型的大致取值范围: - -* 单精度:$±3.40282347 \times 10^{38}$ -* 双精度:$±1.79769313486231570 \times 10^{308}$ - -我们可以直接创建浮点类型的变量: - -```java -public static void main(String[] args) { - double a = 10.5, b = 66; //整数类型常量也可以隐式转换到浮点类型 -} -``` - -注意,跟整数类型常量一样,小数类型常量默认都是`double`类型,所以说如果我们直接给一个float类型赋值: - -![image-20220917105141288](https://s2.loli.net/2022/09/17/x7bOzyIacpDowKk.png) - -由于`float`类型的精度不如`double`,如果直接给其赋一个double类型的值,会直接出现错误。 - -同样的,我们可以给常量后面添加大写或小写的F来表示这是一个`float`类型的常量值: - -```java -public static void main(String[] args) { - float f = 9.9F; //这样就可以正常编译通过了 -} -``` - -但是反之,由于`double`精度更大,所以说可以直接接收`float`类型的值: - -```java -public static void main(String[] args) { - float f = 9.9F; - double a = f; //隐式类型转换为double值 - System.out.println(a); -} -``` - -只不过由于精度问题,最后的打印结果: - -![image-20220917105849862](https://s2.loli.net/2022/09/17/1JqHY2so6Qwz4WX.png) - -这种情况是正常的,因为浮点类型并不保证能够精确计算,我们会在下一章介绍 BigDecimal 和 BigInteger,其中BigDecimal更适合需要精确计算的场景。 - -我们最后来看看下面的例子: - -```java -public static void main(String[] args) { - long l = 21731371236768L; - float f = l; //这里能编译通过吗? - System.out.println(f); -} -``` - -此时我们发现,`long`类型的值居然可以直接丢给`float`类型隐式类型转换,很明显`float`只有32个bit位,而`long`有足足64个,这是什么情况?怎么大的还可以隐式转换为小的?这是因为虽然`float`空间没有那么大,但是由于是浮点类型,指数可以变化,最大的数值表示范围实际上是大于`long`类型的,虽然会丢失精度,但是确实可以表示这么大的数。 - -所以说我们来总结一下隐式类型转换规则:byte→short(char)→int→long→float→double - -### 字符类型 - -字符类型也是一个重要的基本数据类型,它可以表示计算机中的任意一个字符(包括中文、英文、标点等一切可以显示出来的字符) - -- char 字符型(16个bit,也就是2字节,它不带符号)范围是0 ~ 65535 - -可以看到char类型依然存储的是数字,那么它是如何表示每一个字符的呢?实际上每个数字在计算机中都会对应一个字符,首先我们需要介绍ASCII码: - -![img](https://s2.loli.net/2022/09/17/Z7AiBPNO6ylML4z.png) - -比如我们的英文字母`A`要展示出来,那就是一个字符的形式,而其对应的ASCII码值为65,所以说当char为65时,打印出来的结果就是大写的字母A了: - -```java -public static void main(String[] args) { - char c = 65; - System.out.println(c); -} -``` - -得到结果为: - -![image-20220917110854266](https://s2.loli.net/2022/09/17/dvizHYa2fCOKhA3.png) - -或者我们也可以直接写一个字符常量值赋值: - -```java -public static void main(String[] args) { - char c = 'A'; //字符常量值需要使用单引号囊括,并且内部只能有一个字符 - System.out.println(c); -} -``` - -这种写法效果与上面是一样的。 - -不过,我们回过来想想,这里的字符表里面不就128个字符吗,那`char`干嘛要两个字节的空间来存放呢?我们发现表中的字符远远没有我们所需要的那么多,这里只包含了一些基础的字符,中文呢?那么多中文字符(差不多有6000多个),用ASCII编码表那128个肯定是没办法全部表示的,但是我们现在需要在电脑中使用中文。这时,我们就需要扩展字符集了。 - -> 我们可以使用两个甚至多个字节来表示一个中文字符,这样我们能够表示的数量就大大增加了,GB2132方案规定当连续出现两个大于127的字节时(注意不考虑符号位,此时相当于是第一个bit位一直为1了),表示这是一个中文字符(所以为什么常常有人说一个英文字符占一字节,一个中文字符占两个字节),这样我们就可以表示出超过7000种字符了,不仅仅是中文,甚至中文标点、数学符号等,都可以被正确的表示出来。 -> -> 不过这样能够表示的内容还是不太够,除了那些常见的汉字之外,还有很多的生僻字,比如龘、錕、釿、拷这类的汉字,后来干脆直接只要第一个字节大于127,就表示这是一个汉字的开始,无论下一个字节是什么内容(甚至原来的128个字符也被编到新的表中),这就是Windows至今一直在使用的默认GBK编码格式。 -> -> 虽然这种编码方式能够很好的解决中文无法表示的问题,但是由于全球还有很多很多的国家以及很多很多种语言,所以我们的最终目标是能够创造一种可以表示全球所有字符的编码方式,整个世界都使用同一种编码格式,这样就可以同时表示全球的语言了。所以这时就出现了一个叫做ISO的(国际标准化组织)组织,来定义一套编码方案来解决所有国家的编码问题,这个新的编码方案就叫做Unicode(准确的说应该是规定的字符集,包含了几乎全世界所有语言的字符),规定每个字符必须使用两个字节,即用16个bit位来表示所有的字符(也就是说原来的那128个字符也要强行用两位来表示) -> -> 但是这样的话实际上是很浪费资源的,因为这样很多字符都不会用到两字节来保存,肯定不能直接就这样去表示,这会导致某些字符浪费了很多空间,我们需要一个更加好用的具体的字符编码方式。所以最后就有了UTF-8编码格式(它是Unicode字符集的一个编码规则),区分每个字符的开始是根据字符的高位字节来区分的,比如用一个字节表示的字符,第一个字节高位以“0”开头;用两个字节表示的字符,第一个字节的高位为以“110”开头,后面一个字节以“10开头”;用三个字节表示的字符,第一个字节以“1110”开头,后面俩字节以“10”开头;用四个字节表示的字符,第一个字节以“11110”开头,后面的三个字节以“10”开头: -> -> ``` -> 10000011 10000110 //这就是一个连续出现都大于127的字节(注意这里是不考虑符号位的) -> ``` -> -> 所以如果我们的程序需要表示多种语言,最好采用UTF-8编码格式,随着更多的字符加入,实际上两个字节也装不下了,可能需要3个甚至4个字节才能表示某些符号,后来就有了UTF-16编码格式,Java在运行时采用的就是UTF-16,几乎全世界的语言用到的字符都可以表示出来。 - -| Unicode符号范围(十六进制) | UTF-8编码方式(二进制) | -| --------------------------- | ----------------------------------- | -| 0000 0000 ~ 0000 007F | 0xxxxxxx | -| 0000 0080 ~ 0000 07FF | 110xxxxx 10xxxxxx | -| 0000 0800 ~ 0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx | -| 0001 0000 ~ 0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx | - -**注意:**Unicode 是“字符集”,也就是有哪些字符,而UTF-8、UTF-16 是“编码规则”,也就是怎么对这些字符编码,怎么以二进制的形式保存,千万不要搞混了。 - -简而言之,char实际上需要两个字节才能表示更多种类的字符,所以,`char`类型可以直接表示一个中文字符: - -```java -public static void main(String[] args) { - int a = '淦'; //使用int类型接收字符类型常量值可以直接转换为对应的编码 - System.out.println(a); -} -``` - -得到结果为: - -![image-20220917111838629](https://s2.loli.net/2022/09/17/ZgzMUafmYAKoOXt.png) - -Java程序在编译为`.class`文件之后,会采用UTF-8的编码格式,支持的字符也非常多,所以你甚至可以直接把变量名写成中文,依然可以编译通过: - -![image-20220917112033102](https://s2.loli.net/2022/09/17/vAnPCgx5ThEUBHe.png) - -介绍完了字符之后,我们接着来看看字符串,其实字符串我们在一开始就已经接触到了。字符虽然可以表示一个中文,但是它没办法表示多个字符: - -![image-20220917114628564](https://s2.loli.net/2022/09/17/JmdQkSw2qc4ZTuW.png) - -但是实际上我们使用率最高的还是多个字符的情况,我们需要打印一连串的字符。这个时候,我们就可以使用字符串了: - -```java -public static void main(String[] args) { - String str = "啊这"; //字符串需要使用双引号囊括,字符串中可以包含0-N个字符 -} -``` - -注意,这里使用的类型是`String`类型,这种类型并**不是基本数据类型**,它是对象类型,我们会在下一章继续对其进行介绍,这里我们只需要简单了解一下就可以了。 - -### 布尔类型 - -布尔类型是Java中的一个比较特殊的类型,它并不是存放数字的,而是状态,它有下面的两个状态: - -* true - 真 -* false - 假 - -布尔类型(boolean)只有`true`和`false`两种值,也就是要么为真,要么为假,布尔类型的变量通常用作流程控制判断语句(不同于C语言,C语言中一般使用0表示false,除0以外的所有数都表示true)布尔类型占据的空间大小并未明确定义,而是根据不同的JVM会有不同的实现。 - -```java -public static void main(String[] args) { - boolean b = true; //值只能是true或false - System.out.println(b); -} -``` - -如果给一个其他的值,会无法编译通过: - -![image-20220917115424504](https://s2.loli.net/2022/09/17/1TtJdKcvRWPfAI2.png) - -至此,基本数据类型的介绍就结束了。 - -*** - -## 运算符 - -前面我们介绍了多种多样的基本数据类型,但是光有这些基本数据类型还不够,我们还需要让这些数据之间进行运算,才可以真正意义上发挥计算机的作用。 - -要完成计算,我们需要借助运算符来完成,实际上我们在数学中就已经接触过多种多样的运算符了。 - -> 比如:+ - × ÷ - -这些运算符都是我们在初等数学中学习的,而使用规则也很简单,我们只需要将需要进行运算的两个数放到运算符的两边就可以了: - -> 比如:10 ÷ 2 - -上面运算的结果就是5了,而在Java中,我们同样可以使用这样的方式来进行运算。 - -### 赋值运算符 - -首先我们还是来回顾一下之前认识的老朋友:赋值运算符。 - -赋值运算符可以直接给某个变量赋值: - -```java -public static void main(String[] args) { - int a = 666; //使用等号进行赋值运算 -} -``` - -**使用规则为:**赋值运算符的左边必须是一个可以赋值的目标,比如变量,右边可以是任意满足要求的值,包括变量。 - -当然,赋值运算符并不只是单纯的赋值,它是有结果的: - -```java -public static void main(String[] args) { - int a; - int b = a = 777; -} -``` - -当出现连续使用赋值运算符时,按照从右往左的顺序进行计算,首先是`a = 777`,计算完成后,a的值就变成了777,计算完成后,会得到计算结果(赋值运算的计算结果就是赋的值本身,就像1 + 1的结果是2一样,a = 1的结果就是1)此时继续进行赋值计算,那么b就被赋值为`a = 777`的计算结果,同样的也是 777 了。 - -所以,使用连等可以将一连串变量都赋值为最右边的值。 - -### 算术运算符 - -算术运算符也就是我们初等数学中认识的这些运算符,包括加减乘除,当然Java还支持取模运算,算术运算同样需要左右两边都有一个拿来计算的目标。 - -```java -public static void main(String[] args) { - int a = 1 + 1; - System.out.println(a); -} -``` - -可以看到a赋值为1+1的结果,所以说最后a就是2了。 - -当然变量也是可以参与到算术运算中: - -```java -public static void main(String[] args) { - int a = 3; - int b = a - 10; - System.out.println(b); -} -``` - -不同类型之间也可以进行运算: - -```java -public static void main(String[] args) { - int a = 5; - short b = 10; - int c = a + b; - //不同类型的整数一起运算,小类型需要转换为大类型,short、byte、char一律转换为int再进行计算(无论算式中有无int,都需要转换),结果也是int;如果算式中出现了long类型,那么全部都需要转换到long类型再进行计算,结果也是long,反正就是依大的来 -} -``` - -因为运算时会发生隐式类型转换,所以说这里b自动转换为了int类型进行计算,所以说最后得到结果也一定是转换后的类型: - -![image-20220917141359260](https://s2.loli.net/2022/09/17/KovME45pl2sPiBN.png) - -小数和整数一起计算同样会发生隐式类型转换: - -![image-20220917141955891](https://s2.loli.net/2022/09/17/jxW3KfwBACidyMY.png) - -因为小数表示范围更广,所以说整数会被转换为小数再进行计算,而最后的结果也肯定是小数了。 - -我们也可以将加减号作为正负符号使用,比如我们现在需要让a变成自己的相反数: - -```java -public static void main(String[] args) { - int a = 10; - a = -a; //减号此时作为负号运算符在使用,会将右边紧跟的目标变成相反数 - System.out.println(a); //这里就会得到-10了 -} -``` - -同样的,正号也可以使用,但是似乎没什么卵用: - -```java -public static void main(String[] args) { - int a = 10; - a = +a; //正号本身在数学中就是可以省略的存在,所以Java中同样如此 - System.out.println(a); -} -``` - -注意加法支持对字符串的拼接: - -```java -public static void main(String[] args) { - String str = "伞兵" + "lbw"; //我们可以使用加号来拼接两个字符串 - System.out.println(str); -} -``` - -最后这个字符串就变成了拼接后的结果了: - -![image-20220917145901135](https://s2.loli.net/2022/09/17/TeUCBM9ZzINuoa8.png) - -字符串不仅可以跟字符串拼接,也可以跟基本数据类型拼接: - -```java -public static void main(String[] args) { - String str = "伞兵" + true + 1.5 + 'A'; - System.out.println(str); -} -``` - -最后就可以得到对应的结果了: - -![image-20220917150010919](https://s2.loli.net/2022/09/17/URJxsgXvzYMQh8t.png) - -当然,除了加减法之外乘除法也是支持的: - -```java -public static void main(String[] args) { - int a = 8, b = 2; - System.out.println(a * b); //乘法使用*表示乘号 - System.out.println(a / b); //除法就是一个/表示除号 -} -``` - -注意,两个整数在进行除法运算时,得到的结果也是整数(会直接砍掉小数部分,注意不是四舍五入) - -```java -public static void main(String[] args) { - int a = 8, b = 5; - System.out.println(a / b); -} -``` - -上面是两个int类型的值进行的除法运算,正常情况下8除以5应该得到1.6,但是由于结果也是整数,所以说最后小数部分被丢弃: - -![image-20220917141816599](https://s2.loli.net/2022/09/17/TdhHPN64UnyFozq.png) - -但是如果是两个小数一起计算的话,因为结果也是小数,所以说就可以准确得到结果: - -```java -public static void main(String[] args) { - double a = 8.0, b = 5.0; - System.out.println(a / b); -} -``` - -![image-20220917142201392](https://s2.loli.net/2022/09/17/3zjJoeL6bgdRqNA.png) - -同样的,整数和小数一起计算,由于所有的整数范围都比小数小,根据我们上一部分介绍的转换规则,整数和小数一起计算时,所有的整数都会变成小数参与运算,所以说最后的结果也就是小数了,同样可以得到正确的结果: - -```java -public static void main(String[] args) { - double a = 8.0; - int b = 5; - System.out.println(a / b); -} -``` - -那么问题来了,现在我们有两个整数需要进行计算,但是我们就是希望可以得到一个小数的结果该怎么办呢? - -```java -public static void main(String[] args) { - int a = 8, b = 5; - double c = a; //我们可以将其先隐式转换为小数类型,再那转换后的小数变量去参与计算 - System.out.println(c / b); //同样可以得到正确结果 -} -``` - -在下一节,我们将介绍强制类型转换,通过使用强制类型转换,我们可以更轻松地让整数计算出小数的结果。 - -除了最基本的加减乘除操作,我们也可以进行取模操作: - -```java -public static void main(String[] args) { - int a = 10; - System.out.println(a % 3); //比如这里对a进行取模操作,实际上就是计算除以3的余数 -} -``` - -比如上面的是 10 % 3 得到的结果就是10除以3最后的余数1,取模操作也是非常好用的。 - -比如我们查看某个数是否为双数,只需要将其对2进行取模操作即可,因为如果是双数一定是可以整除的,如果是单数一定会余1: - -```java -public static void main(String[] args) { - System.out.println(17 % 2); //17不是双数,所以说最后会得到1 -} -``` - -注意,运算符之间是有优先级之分的,比如乘除法优先级高于加减法: - -```java -public static void main(String[] args) { - System.out.println(10 + 3 * 4); -} -``` - -上面的算式按照数学中的规则,应该先计算3 * 4,然后再进行加法计算,而Java中同样遵循这样的规律。我们来总结一下到目前为止所有学习到的运算符相关性质: - -| 优先级 | 运算符 | 结合性(出现同优先级运算符时) | -| :----: | :---------------------------: | :----------------------------: | -| 1 | -(负号) +(正号) | 从右向左 | -| 2 | * / % | 从左往右 | -| 3 | +(加法,包括字符串) -(减法) | 从左往右 | -| 4 | = | 从右向左 | - -比如下面的结果: - -```java -public static void main(String[] args) { - int a = 10; - int b = a = 8 * -a + 10; - /* - 1. 正负号优先级最高,所有首先计算的是-a,得到-10 - 2. 其次是乘除号优先级更高,所以说这里计算 8 * -10,得到 -80 - 3. 然后是加减法,-80 + 10 = -70 - 4. 最后是赋值运算,因为等号运算符从右往左结合,先算a = -70的结果就是 -70 - 5. 最后b就是 -70 - */ - System.out.println(b); -} -``` - -通过使用这些基本算术运算符,我们就可以更加快速地计算我们想要的结果了。 - -### 括号运算符 - -前面我们介绍了算术运算符,我们接着来看括号运算符。 - -我们常常在数学中使用括号提升某些运算的优先级,比如: - -> (1 + 7) × (3 - 6) = -24 - -虽然加法优先级比乘法要低但是我们给其添加括号之后,相当于提升了内部加法运算的优先级,所以说需要先计算括号中的再去计算括号外的,Java同样满足这个要求。 - -我们可以通过添加括号的方式来提升某些运算的优先级: - -```java -public static void main(String[] args) { - int a = 10; - int b = (a = 8) * (-a + 10); - /* - 1. 括号的优先级是最高的,我们需要先计算括号中的内容,如果存在多个括号,就从左往右计算 - 2. 首先是 a = 8,计算完成之后a变成8,并且运算结果也为8 - 3. 然后是后面的加法,-a就是-8,加上10就是2 - 4. 最后才是乘法,左边此时是8,右边是2,最后结果为16 - */ - System.out.println(b); -} -``` - -所以,通过添加括号,就可以更加灵活的控制计算。 - -当然,括号是可以嵌套的,这一点跟数学中也是一样的,只不过我们不需要使用方括号和花括号,一律使用小括号就行了。 - -在嵌套的情况下,会优先计算最内层括号中的算式: - - -```java -public static void main(String[] args) { - int b = (2 + (3 + 1) * 3) * 2; - System.out.println(b); -} -``` - -这里会优先计算 3 + 1的结果,然后由于第二层都在一个括号中,所以说按照正常优先级计算,2 + 4 * 3 = 14,最后来到最外层14*2 = 28,计算结束。 - -括号除了可以用来提升运算优先级,也可以用作**强制类型转换**,前面我们介绍了隐式类型转换,但是隐式类型转换存在局限性,比如此时我们希望将一个大的类型转换为一个小的类型: - -![image-20220917150256987](https://s2.loli.net/2022/09/17/En2uzTl5PFgKeNX.png) - -正常情况下无法编译通过,但是实际上a的值并没有超出`short`的范围,理论上是可以直接给到b存放的,此时我们就可以使用强制类型转换: - -```java -public static void main(String[] args) { - int a = 10; - short b = (short) a; //在括号中填写上强制转换的类型,就可以强制转换到对应的类型了 -} -``` - -只不过强制类型转换存在一定的风险,比如: - -```java -public static void main(String[] args) { - int a = 128; //已经超出byte的范围了 - byte b = (byte) a; //此时强制类型转换为byte类型,那么只会保留byte能够表示的bit位 - System.out.println(b); -} -``` - -比如这里的128: - -* 00000000 00000000 00000000 10000000 -> byte只有一个字节,所以说只保留最后8位 -> 10000000 - -这里的10000000,由于第一个位置是符号位,导致此时直接变成了byte的最小值: - -![image-20220917151028191](https://s2.loli.net/2022/09/17/Kt6rfkYE1HSvNnl.png) - -所以说强制类型转换只有在明确不会出现问题的情况下,才可以使用。当然,强制类型转换也可以用在后面的类中,我们将会在下一章继续探讨。 - -有了强制类型转换,我们就可以很轻松地让两个整数计算出小数的结果了: - -```java -public static void main(String[] args) { - int a = 8, b = 5; - double c = a/(double)b; - //强制类型转换的优先级跟正负号一样 - //计算时,只需要将其中一者转换为double类型,此时按照隐式类型转换规则,全都会变成double参与运算,所以结果也就是小数了 - System.out.println(c); -} -``` - -各位思考一下下面的这种情况可以正确得到小数的结果吗? - -```java -public static void main(String[] args) { - int a = 8, b = 5; - double c = (double) (a/b); - System.out.println(c); -} -``` - -不能得到,因为括号将a/b的运算优先进行了,此时得到的结果已经是一个整数结果,再转换为double毫无意义。 - -最后我们还是来总结一下目前遇到的所有运算符: - -| 优先级 | 运算符 | 结合性 | -| :----: | :---------------------------: | :------: | -| 1 | ( ) | 从左向右 | -| 2 | - + (强制类型转换) | 从右向左 | -| 3 | * / % | 从左向右 | -| 4 | +(加法,包括字符串) -(减法) | 从左向右 | -| 5 | = | 从右向左 | - -### 自增自减运算符 - -**注意:**这一节很容易搞晕,请务必记清楚顺序! - -有时候我们可能需要让变量自己进行增加操作,比如我们现在想要进行跳绳计数,每转动一圈,计数+1,当我们想要对一个变量进行这样的自增操作时,可以: - -```java -public static void main(String[] args) { - int a = 8; - a = a + 1; //让a等于a本身+1,相当于自增了1 - System.out.println(a); //得到9 -} -``` - -当然,除了这种方式,我们也可以使用自增自减运算符: - -```java -public static void main(String[] args) { - int a = 8; - a++; //自增运算符就是两个加号连在一起,效果跟上面是一样的,a都会自增1 - a--; //自减不用我多说了吧 - System.out.println(a); -} -``` - -自增自减运算符可以放到操作数的前后: - -```java -public static void main(String[] args) { - int a = 8; - ++a; //自增运算符在前在后最终效果都是让a自增1,是一样的 - System.out.println(a); -} -``` - -自增自减操作同样是有结果的,注意,这两种方式自增操作的结果不一样,我们来看下面的例子: - -```java -public static void main(String[] args) { - int a = 8; - int b = a++; //先出结果,再自增 - System.out.println(b); //b得到的是a自增前的值 -} -``` - -```java -public static void main(String[] args) { - int a = 8; - int b = ++a; //先自增,再出结果 - System.out.println(b); //b得到的是a自增之后的结果 -} -``` - -第一个结果为8,而第二个结果却是9,这是因为,自增运算符放在前面,是先自增再得到结果,而自增运算符放到后面,是先出结果再自增(自减同理),这个新手很容易记混,所以说一定要分清楚。 - -自增自减运算符的优先级与正负号等价比如: - -```java -public static void main(String[] args) { - int a = 8; - int b = -a++ + ++a; - //我们首先来看前面的a,因为正负号和自增是同一个优先级,结合性是从右往左,所以说先计算a++ - //a++的结果还是8,然后是负号,得到-8 - //接着是后面的a,因为此时a已经经过前面变成9了,所以说++a就是先自增,再得到10 - //最后得到的结果为 -8 + 10 = 2 - System.out.println(b); -} -``` - -一般情况下,除了考试为了考察各位小伙伴对运算符的优先级和结合性的理解,会出现这种恶心人的写法之外,各位小伙伴尽量不要去写这种难以阅读的东西。 - -当然,有些时候我们并不是希望以1进行自增,可能希望以其他的数进行自增操作,除了按照之前的方式老老实实写之外: - -```java -public static void main(String[] args) { - int a = 8; - a = a + 4; - System.out.println(a); -} -``` - -我们可以将其缩写: - -```java -public static void main(String[] args) { - int a = 8; - a += 4; //加号和等号连在一起,与a = a + 4效果完全一样 - System.out.println(a); -} -``` - -并且结果也是操作之后的结果: - -```java -public static void main(String[] args) { - int a = 8; - int b = a += 4; //+=的运算结果就是自增之后的结果 - System.out.println(b); //所以b就是12 -} -``` - -不止加法,包括我们前面介绍的全部算术运算符,都是支持这种缩写的: - -```java -public static void main(String[] args) { - int a = 8; - a *= 9; //跟 a = a * 9 等价 - System.out.println(a); //得到72 -} -``` - -是不是感觉能够编写更简洁的代码了? - -| 优先级 | 运算符 | 结合性 | -| :----: | :---------------------------: | :------: | -| 1 | ( ) | 从左向右 | -| 2 | - + (强制类型转换) ++ -- | 从右向左 | -| 3 | * / % | 从左向右 | -| 4 | +(加法,包括字符串) -(减法) | 从左向右 | -| 5 | = += -= *= /= %= | 从右向左 | - -### 位运算符 - -我们接着来看位运算符,它比较偏向于底层,但是只要各位小伙伴前面的计算机二进制表示听明白了,这里就不是问题。 - -我们可以使用位运算符直接以二进制形式操作目标,位运算符包括:& | ^ ~ - -我们先来看按位与&,比如下面的两个数: - -```java -public static void main(String[] args) { - int a = 9, b = 3; - int c = a & b; //进行按位与运算 - System.out.println(c); -} -``` - -按位与实际上就是让这两个数每一位都进行比较,如果这一位两个数都是1,那么结果就是1,否则就是0: - -* a = 9 = 1001 -* b = 3 = 0011 -* c = 1 = 0001(因为只有最后一位,两个数都是1,所以说结果最后一位是1,其他都是0) - -同样的,按位或,其实就是只要任意一个为1(不能同时为0)那么结果就是1: - -```java -public static void main(String[] args) { - int a = 9, b = 3; - int c = a | b; - System.out.println(c); -} -``` - -* a = 9 = 1001 -* b = 3 = 0011 -* c =11= 1011(只要上下有一个是1或者都是1,那结果就是1) - -按位异或符号很多小伙伴会以为是乘方运算,但是Java中并没有乘方运算符,`^`是按位异或运算符,不要记错了。 - -```java -public static void main(String[] args) { - int a = 9, b = 3; - int c = a ^ b; - System.out.println(c); -} -``` - -异或的意思就是只有两边不相同的情况下,结果才是1,也就是说一边是1一边是0的情况: - -* a = 9 = 1001 -* b = 3 = 0011 -* c =10= 1010(从左往右第二位、第四位要么两个都是0,要么两个都是1,所以说结果为0) - -按位取反操作跟前面的正负号一样,只操作一个数,最好理解,如果这一位上是1,变成0,如果是0,变成1: - -```java -public static void main(String[] args) { - byte c = ~127; - System.out.println(c); -} -``` - -* 127 = 01111111 -* -128 = 10000000 - -所以说计算的结果就是-128了。 - -除了以上的四个运算符之外,还有位移运算符,比如: - -```java -public static void main(String[] args) { - byte c = 1 << 2; //两个连续的小于符号,表示左移运算 - System.out.println(c); -} -``` - -* 1 = 00000001 -* 4 = 00000100(左移两位之后,1跑到前面去了,尾部使用**0**填充,此时就是4) - -我们发现,左移操作每进行一次,结果就会x2,所以说,除了直接使用`*`进行乘2的运算之外,我们也可以使用左移操作来完成。 - -同样的,右移操作就是向右移动每一位咯: - -```java -public static void main(String[] args) { - byte c = 8 >> 2; - System.out.println(c); -} -``` - -* 8 = 00001000 -* 2 = 00000010(右移两位之后,1跑到后面去了,头部使用**符号位数字**填充,此时变成2) - -跟上面一样,右移操作可以快速进行除以2的计算。 - -对于负数来说,左移和右移操作不会改变其符号位上的数字,符号位不受位移操作影响: - -```java -public static void main(String[] args) { - byte c = -4 >> 1; - System.out.println(c); -} -``` - -* -4 = 11111100 -* -2 = 11111110(前面这一长串1都被推到后面一位了,因为是负数,头部需要使用**符号位数字**来进行填充) - -我们来总结一下: - -* **左移操作<<:**高位直接丢弃,低位补0 -* **右移操作>>:**低位直接丢弃,符号位是什么高位补什么 - -我们也可以使用考虑符号位的右移操作,一旦考虑符号位,那么符号会被移动: - -```java -public static void main(String[] args) { - int c = -1 >> 1; //正常的右移操作,高位补1,所以说移了还是-1 - System.out.println(c); -} -``` - -```java -public static void main(String[] args) { - int c = -1 >>> 1; //无符号右移是三个大于符号连在一起,移动会直接考虑符号位 - System.out.println(c); -} -``` - -比如: - -* -1 = 11111111 11111111 11111111 11111111 -* 右移: 01111111 11111111 11111111 11111111(无符号右移使用0填充高位) - -此时得到的结果就是正数的最大值 2147483647 了,注意,不存在无符号左移。 - -位移操作也可以缩写: - -```java -public static void main(String[] args) { - int c = -1; - c = c << 2; - System.out.println(c); -} -``` - -可以缩写为: - -```java -public static void main(String[] args) { - int c = -1; - c <<= 2; //直接运算符连上等号即可,跟上面是一样的 - System.out.println(c); -} -``` - -最后我们还是来总结一下优先级: - -| 优先级 | 运算符 | 结合性 | -| :----: | :------------------------------------------------------: | :------: | -| 1 | ( ) | 从左向右 | -| 2 | ~ - + (强制类型转换) ++ -- | 从右向左 | -| 3 | * / % | 从左向右 | -| 4 | + - | 从左向右 | -| 5 | << >> >>> | 从左向右 | -| 6 | & | 从左向右 | -| 7 | ^ | 从左向右 | -| 8 | \| | 从左向右 | -| 9 | = += -= *= /= %= &= \|= ^= <<= >>= >>>= | 从右向左 | - -### 关系运算符 - -到目前为止,我们发现有一个基本数据类型很低调,在前面的计算中`boolean`类型一直都没有机会出场,而接下来就是它的主场。 - -我们可以对某些事物进行判断,比如我们想判断两个变量谁更大,我们可以使用关系运算符: - -```java -public static void main(String[] args) { - int a = 10, b = 20; - boolean c = a > b; //进行判断,如果a > b那么就会得到true,否则会得到false -} -``` - -关系判断的结果只可能是真或是假,所以说得到的结果是一个`boolean`类型的值。 - -关系判断运算符包括: - -``` -> 大于 -< 小于 -== 等于(注意是两个等号连在一起,不是一个等号,使用时不要搞混了) -!= 不等于 ->= 大于等于 -<= 小于等于 -``` - -关系运算符的计算还是比较简单的。 - -### 逻辑运算符 - -前面我们介绍了简单的关系运算符,我们可以通过对关系的判断得到真或是假的结果,但是只能进行简单的判断,如果此时我们想要判断a是否小于等于100且大于等于60,就没办法了: - -![image-20220917223047110](https://s2.loli.net/2022/09/17/Z1yAPOKe8IVvFUt.png) - -注意不能像这样进行判断,这是错误的语法,同时只能使用其中一种关系判断运算符。 - -为了解决这种问题,我们可以使用逻辑运算符,逻辑运算符包括: - -```java -&& 与运算,要求两边同时为true才能返回true -|| 或运算,要求两边至少要有一个为true才能返回true -! 非运算,一般放在表达式最前面,表达式用括号扩起来,表示对表达式的结果进行反转 -``` - -现在,我们就可以使用逻辑运算符进行复杂条件判断: - -```java -public static void main(String[] args) { - int a = 10; - boolean b = 100 >= a && a >= 60; //我们可以使用与运算符连接两个判断表达式 -} -``` - -与运算符要求左右两边同时为真,得到的结果才是真,否则一律为假,上面的判断虽然满足第一个判断表达式,但是不满足第二个,所以说得到的结果就是`false`。 - -我们再来看下面的这个例子: - -```java -public static void main(String[] args) { - int a = 150; - boolean b = 100 >= a && a >= 60; //此时上来就不满足条件 -} -``` - -这个例子中,第一个判断表达式就得到了`false`,此时不会再继续运行第二个表达式,而是直接得到结果`false`(逻辑运算符会出现短路的情况,只要第一个不是真,就算第二个是真也不可能了,所以说为了效率,后续就不用再判断了,在使用时一定要注意这一点) - -同样的,比如我们现在要判断a要么大于10,要么小于0,这种关系就是一个或的关系: - -```java -public static void main(String[] args) { - int a = 150; - boolean b = a < 0 || a > 10; //或运算要求两边只要有至少一边满足,结果就为true,如果都不满足,那么就是false -} -``` - -或运算同样会出现短路的情况,比如下面的例子: - -```java -public static void main(String[] args) { - int a = -9; - boolean b = a < 0 || a > 10; //此时上来就满足条件 -} -``` - -因为第一个判断表达式就直接得到了`true`,那么第二个表达式无论是真还是假,结果都一定是`true`,所以说没必要继续向后进行判断了,直接得到结果`true`。 - -我们来看看下面的结果是什么: - -```java -public static void main(String[] args) { - int a = 10; - boolean b = a++ > 10 && ++a == 12; - System.out.println("a = "+a + ", b = "+b); -} -``` - -得到结果为: - -![image-20220917224320699](https://s2.loli.net/2022/09/17/tJQxnace7y4VdlY.png) - -这是为什么呢?很明显我们的判断中a进行了两次自增操作,但是最后a的结果却是11,这是因为第一个表达式判断的结果为`false`,由于此时进行的是与运算,所以说直接短路,不会再继续判断了,因此第二个表达式就不会执行。 - -当然,除了与运算和或运算,还有一个非运算,这个就比较简单了,它可以将结果变成相反的样子,比如: - -```java -public static void main(String[] args) { - int a = 10; - boolean b = !(a > 5); //对a>5的判断结果,进行非运算 -} -``` - -因为上面的a > 5判断为真,此时进行非运算会得到相反的结果,所以说最后b就是`false`了。 - -最后我们还需要介绍一个叫做三元运算符的东西,三元运算符可以根据判断条件,返回不同的结果,比如我们想要判断: - -* 当a > 10时,给b赋值'A' -* 当a <= 10时,给b赋值'B' - -我们就可以使用三元运算符来完成: - -```java -public static void main(String[] args) { - int a = 10; - char b = a > 10 ? 'A' : 'B'; //三元运算符需要三个内容,第一个是判断语句,第二个是满足判断语句的值,第三个是不满足判断语句的值 - System.out.println(b); -} -``` - -三元运算符: - -``` -判断语句 ? 结果1 : 结果2 -``` - -因此,上面的判断为假,所以说返回的是结果2,那么最后b得到的就是`B`这个字符了。 - -最后,我们来总结整个运算符板块学习到的所有运算符: - -| 优先级 | 运算符 | 结合性 | -| :----: | :------------------------------------------------------: | :------: | -| 1 | ( ) | 从左向右 | -| 2 | ~ - + (强制类型转换) ++ -- | 从右向左 | -| 3 | * / % | 从左向右 | -| 4 | + - | 从左向右 | -| 5 | << >> >>> | 从左向右 | -| 6 | > < >= >= | 从左向右 | -| 7 | == != | 从左向右 | -| 8 | & | 从左向右 | -| 9 | ^ | 从左向右 | -| 10 | \| | 从左向右 | -| 11 | && | 从左向右 | -| 12 | \|\| | 从左向右 | -| 13 | ? : | 从右向左 | -| 14 | = += -= *= /= %= &= \|= ^= <<= >>= >>>= | 从右向左 | - -至此,我们已经学习了Java基础部分中所有的运算符。 - -*** - -## 流程控制 - -我们的程序都是从上往下依次运行的,但是,仅仅是这样还不够,我们需要更加高级的控制语句来使得程序更加有趣。比如,判断一个整数变量,大于1则输出yes,小于1则输出no,这时我们就需要用到选择结构来帮助我们完成条件的判断和程序的分支走向。学习过C语言就很轻松! - -在前面我们介绍了运算符,我们可以通过逻辑运算符和关系运算符对某些条件进行判断,并得到真或是假的结果。这一部分我们将继续使用这些运算符进行各种判断。 - -### 代码块与作用域 - -在开始流程控制语句之前,我们先来介绍一下代码块和作用域。 - -不知道各位小伙伴是否在一开始就注意到了,为什么程序中会有一些成对出现的花括号?这些花括号代表什么意思呢? - -```java -public class Main { //外层花括号 - public static void main(String[] args) { //内层花括号开始 - - } //内层花括号结束 -} -``` - -我们可以在花括号中编写一句又一句的代码,实际上这些被大括号囊括起来的内容,我们就称为**块**(代码块),一个代码块中可以包含多行代码,我们可以在里面做各种各样的事情,比如定义变量、进行计算等等。 - -我们可以自由地创建代码块: - -```java -public static void main(String[] args) { //现目前这个阶段,我们还是在主方法中编写代码,不要跑去外面写 - System.out.println("外层"); - { //自由创建代码块 - int a = 10; - System.out.println(a); - } -} -``` - -虽然创建了代码块,但实际上程序依然是按照从上到下的顺序在进行的,所以说这里还是在逐行运行,即使使用花括号囊括。那咋一看这不就是没什么卵用吗?我们来看看变量。 - -我们创建的变量,实际上是有作用域的,并不是在任何地方都可以使用,比如: - -![image-20220917231014796](https://s2.loli.net/2022/09/17/DdvU3aQmE25KbxM.png) - -变量的使用范围,仅限于其定义时所处的代码块,一旦超出对应的代码块区域,那么就相当于没有这个变量了。 - -```java -public static void main(String[] args) { - int a = 10; //此时变量在最外层定义 - { - System.out.println(a); //处于其作用域内部的代码块可以使用 - } - System.out.println(a); //这里肯定也可以使用 -} -``` - -我们目前所创建的变量都是局部变量(有范围限制),后面我们会介绍更多种类型的变量,了解了代码块及作用域之后,我们就可以正式开启流程控制语句的学习了。 - -### 选择结构 - -某些时候,我们希望进行判断,只有在条件为真时,才执行某些代码,这种情况就需要使用到选择分支语句,首先我们来认识一下`if`语句: - -``` -if (条件判断) 判断成功执行的代码; -``` - -```java -public static void main(String[] args) { - int a = 15; - if(a == 15) //只有当a判断等于15时,才会执行下面的打印语句 - System.out.println("Hello World!"); - System.out.println("我是外层"); //if只会对紧跟着的一行代码生效,后续的内容无效 -} -``` - -`if`会进行判断,只有判断成功时才会执行紧跟着的语句,否则会直接跳过,注意,如果我们想要在if中执行多行代码,需要使用代码块将这些代码囊括起来(实际上代码块就是将多条语句复合到一起)所以说,我们以后使用if时,如果分支中有多行代码需要执行,就需要添加花括号,如果只有一行代码,花括号可以直接省略,包括我们后面会讲到的else、while、for语句都是这样的。 - -```java -public static void main(String[] args) { - int a = 15; - if(a > 10) { //只有判断成功时,才会执行下面的代码块中内容,否则直接跳过 - System.out.println("a大于10"); - System.out.println("a的值为:"+a); - } - System.out.println("我是外层"); -} -``` - -如果我们希望判断条件为真时执行某些代码,条件为假时执行另一些代码,我们可以在后面继续添加else语句: - -```java -public static void main(String[] args) { - int a = 15; - if(a > 10) { //只有判断成功时,才会执行下面的代码块中内容,否则直接跳过 - System.out.println("a大于10"); - System.out.println("a的值为:"+a); - } else { //当判断不成功时,会执行else代码块中的代码 - System.out.println("a小于10"); - System.out.println("a的值为:"+a); - } - System.out.println("我是外层"); -} -``` - -`if-else`语句就像两个分支,跟据不同的判断情况从而决定下一步该做什么,这跟我们之前认识的三元运算符性质比较类似。 - -那如果此时我们需要判断多个分支呢?比如我们现在希望判断学生的成绩,不同分数段打印的等级不一样,比如90以上就是优秀,70以上就是良好,60以上是及格,其他的都是不及格,那么这种我们又该如何判断呢?要像这样进行连续判断,我们需要使用`else-if`来完成: - -```java -public static void main(String[] args) { - int score = 2; - if(score >= 90) //90分以上才是优秀 - System.out.println("优秀"); - else if (score >= 70) //当上一级if判断失败时,会继续判断这一级 - System.out.println("良好"); - else if (score >= 60) - System.out.println("及格"); - else //当之前所有的if都判断失败时,才会进入到最后的else语句中 - System.out.println("不及格"); -} -``` - -当然,`if`分支语句还支持嵌套使用,比如我们现在希望低于60分的同学需要补习,0-30分需要补Java,30-60分需要补C++,这时我们就需要用到嵌套: - -```java -public static void main(String[] args) { - int score = 2; - if(score < 60) { //先判断不及格 - if(score > 30) //在内层再嵌套一个if语句进行进一步的判断 - System.out.println("学习C++"); - else - System.out.println("学习Java"); - } -} -``` - -除了if自己可以进行嵌套使用之外,其他流程控制语句同样可以嵌套使用,也可以与其他流程控制语句混合嵌套使用。这样,我们就可以灵活地使用`if`来进行各种条件判断了。 - -前面我们介绍了if语句,我们可以通过一个if语句轻松地进行条件判断,然后根据对应的条件,来执行不同的逻辑,当然除了这种方式之外,我们也可以使用`switch`语句来实现,它更适用于多分支的情况: - -```java -switch (目标) { //我们需要传入一个目标,比如变量,或是计算表达式等 - case 匹配值: //如果目标的值等于我们这里给定的匹配值,那么就执行case后面的代码 - 代码... - break; //代码执行结束后需要使用break来结束,否则会溜到下一个case继续执行代码 -} -``` - -比如现在我们要根据学生的等级进行分班,学生有ABC三个等级: - -```java -public static void main(String[] args) { - char c = 'A'; - switch (c) { //这里目标就是变量c - case 'A': //分别指定ABC三个匹配值,并且执行不同的代码 - System.out.println("去尖子班!准备冲刺985大学!"); - break; //执行完之后一定记得break,否则会继续向下执行下一个case中的代码 - case 'B': - System.out.println("去平行班!准备冲刺一本!"); - break; - case 'C': - System.out.println("去职高深造。"); - break; - } -} -``` - -`switch`可以精准匹配某个值,但是它不能进行范围判断,比如我们要判断分数段,这时用switch就很鸡肋了。 - -当然除了精准匹配之外,其他的情况我们可以用default来表示: - -```java -switch (目标) { - case: ... - default: - 其他情况下执行的代码 -} -``` - -我们还是以刚才那个例子为例: - -```java -public static void main(String[] args) { - char c = 'A'; - switch (c) { - case 'A': - System.out.println("去尖子班!"); - break; - case 'B': - System.out.println("去平行班!"); - break; - case 'C': - System.out.println("去差生班!"); - break; - default: //其他情况一律就是下面的代码了 - System.out.println("去读职高,分流"); - } -} -``` - -当然switch中可以继续嵌套其他的流程控制语句,比如if: - -```java -public static void main(String[] args) { - char c = 'A'; - switch (c) { - case 'A': - if(c == 'A') { //嵌套一个if语句 - System.out.println("去尖子班!"); - } - break; - case 'B': - System.out.println("去平行班!"); - break; - } -} -``` - -目前,我们已经认识了两种选择分支结构语句。 - -### 循环结构 - -通过前面的学习,我们了解了如何使用分支语句来根据不同的条件执行不同的代码,我们接着来看第二种重要的流程控制语句:循环语句。 - -我们在某些时候,可能需要批量执行某些代码: - -```java -public static void main(String[] args) { - System.out.println("伞兵一号卢本伟准备就绪!"); //把这句话给我打印三遍 - System.out.println("伞兵一号卢本伟准备就绪!"); - System.out.println("伞兵一号卢本伟准备就绪!"); -} -``` - -遇到这种情况,我们由于还没学习循环语句,那么就只能写N次来实现这样的多次执行。但是如果此时要求我们将一句话打印100遍、1000遍、10000遍,那么我们岂不是光CV代码就要搞一下午? - -现在,要解决这种问题,我们可以使用for循环语句来多次执行: - -```java -for (表达式1;表达式2;表达式3) 循环体; -``` - -介绍一下详细规则: - -- 表达式1:在循环开始时仅执行一次。 -- 表达式2:每次循环开始前会执行一次,要求为判断语句,用于判断是否可以结束循环,若结果为真,那么继续循环,否则结束循环。 -- 表达式3:每次循环完成后会执行一次。 -- 循环体:每次循环都会执行一次循环体。 - -一个标准的for循环语句写法如下: - -```java -public static void main(String[] args) { - //比如我们希望让刚刚的打印执行3次 - for (int i = 0; i < 3; i++) //这里我们在for语句中定义一个变量i,然后每一轮i都会自增,直到变成3为止 - System.out.println("伞兵一号卢本伟准备就绪!"); //这样,就会执行三轮循环,每轮循环都会执行紧跟着的这一句打印 -} -``` - -我们可以使用调试来观察每一轮的变化,调试模式跟普通的运行一样,也会执行我们的Java程序,但是我们可以添加断点,也就是说当代码运行到断点位置时,会在这里暂停,我们可以观察当代码执行到这个位置时各个变量的值: - -![image-20220918112006020](https://s2.loli.net/2022/09/18/A8lRmNZCqxLStwQ.png) - -调试模式在我们后面的学习中非常重要,影响深远,所以说各位小伙伴一定要学会。调试也很简单,我们只需要点击右上角的调试选项即可(图标像一个小虫子一样,因为调试的英文名称是Debug) - -![image-20220918112101677](https://s2.loli.net/2022/09/18/VKMGoJazvXAnh2k.png) - -调试开始时,我们可以看到程序在断点位置暂停了: - -![image-20220918112227207](https://s2.loli.net/2022/09/18/Cdq1ifFvHwMuO29.png) - -此时我们可以观察到当前的局部变量`i`的值,也可以直接在下方的调试窗口中查看: - -![image-20220918112409944](https://s2.loli.net/2022/09/18/e6AODRMCgqmGwTy.png) - -随着循环的进行,i的值也会逐渐自增: - -![image-20220918112628585](https://s2.loli.net/2022/09/18/bS1DxpgwOfWhujy.png) - -当`i`增长到2时,此时来到最后一轮循环,再继续向下运行,就不再满足循环条件了,所以说此时就会结束循环。 - -当然,如果要执行多条语句的话,只需要使用花括号囊括就行了: - -```java -for (int i = 0; i < 3; i++) { - System.out.println("伞兵一号卢本伟准备就绪!"); - System.out.println("当前i的值为:"+i); -} -``` - -注意这里的`i`仅仅是for循环语句中创建的变量,所以说其作用域被限制在了循环体中,一旦离开循环体,那么就无法使用了: - -![image-20220918112923978](https://s2.loli.net/2022/09/18/2aO9Ro5yfMUvhNc.png) - -但是我们可以将`i`的创建放到外面: - -```java -public static void main(String[] args) { - int i = 0; //在外面创建变量i,这样全部范围内都可以使用了 - for (; i < 3; i++) { //for循环的三个表达式并不一定需要编写 - System.out.println("伞兵一号卢本伟准备就绪!"); - System.out.println("当前i的值为:"+i); - } - System.out.println("当前i的值为:"+i); -} -``` - -和之前的`if`一样,for循环同样支持嵌套使用: - -```java -public static void main(String[] args) { - for (int i = 0; i < 3; i++) //外层循环执行3次 - for (int j = 0; j < 3; j++) //内层循环也执行3次 - System.out.println("1!5!"); -} -``` - -上面的代码中,外层循环会执行3轮,而整个循环体又是一个循环语句,那么也就是说,每一轮循环都会执行里面的整个循环,里面的整个循环会执行3,那么总共就会执行3 x 3次,也就是9次打印语句。 - -实际上,for循环的三个表达式并不一定需要编写,我们甚至可以三个都不写: - -```java -public static void main(String[] args) { - for (;;) //如果什么都不写,相当于没有结束条件,这将会导致无限循环 - System.out.println("伞兵一号卢本伟准备就绪!"); -} -``` - -如果没有表达式2,那么整个for循环就没有结束条件,默认会判定为真,此时就会出现无限循环的情况(无限循环是很危险的,因为它会疯狂地消耗CPU资源来执行循环,可能很快你的CPU就满载了,一定要避免) - -当然,我们也可以在循环过程中提前终止或是加速循环的进行,这里我们需要认识两个新的关键字: - -```java -public static void main(String[] args) { - for (int i = 0; i < 3; i++) { - if(i == 1) continue; //比如我们希望当i等于1时跳过这一轮,不执行后面的打印 - System.out.println("伞兵一号卢本伟准备就绪!"); - System.out.println("当前i的值为:"+i); - } -} -``` - -我们可以使用`continue`关键字来跳过本轮循环,直接开启下一轮。这里的跳过是指,循环体中,无论后面有没有未执行的代码,一律不执行,比如上面的判断如果成功,那么将执行`continue`进行跳过,虽然后面还有打印语句,但是不会再去执行了,而是直接结束当前循环,开启下一轮。 - -在某些情况下,我们可能希望提前结束循环: - -```java -for (int i = 0; i < 3; i++) { - if(i == 1) break; //我们希望当i等于1时提前结束 - System.out.println("伞兵一号卢本伟准备就绪!"); - System.out.println("当前i的值为:"+i); -} -``` - -我们可以使用`break`关键字来提前终止整个循环,和上面一样,本轮循环中无论后续还有没有未执行的代码,都不会执行了,而是直接结束整个循环,跳出到循环外部。 - -虽然使用break和continue关键字能够更方便的控制循环,但是注意在多重循环嵌套下,它只对离它最近的循环生效(就近原则): - -```java -for (int i = 1; i < 4; ++i) { - for (int j = 1; j < 4; ++j) { - if(i == j) continue; //当i == j时加速循环 - System.out.println(i+", "+j); - } -} -``` - -这里的`continue`加速的对象并不是外层的for,而是离它最近的内层for循环,`break`也是同样的规则: - -```java -for (int i = 1; i < 4; ++i) { - for (int j = 1; j < 4; ++j) { - if(i == j) break; //当i == j时终止循环 - System.out.println(i+", "+j); - } -} -``` - -那么,要是我们就是想要终止或者是加速外层循环呢?我们可以为循环语句打上标记: - -```java -outer: for (int i = 1; i < 4; ++i) { //在循环语句前,添加 标签: 来进行标记 - inner: for (int j = 1; j < 4; ++j) { - if(i == j) break outer; //break后紧跟要结束的循环标记,当i == j时终止外层循环 - System.out.println(i+", "+j); - } -} -``` - -如果一个代码块中存在多个循环,那么直接对当前代码块的标记执行`break`时会直接跳出整个代码块: - -```java -outer: { //直接对整个代码块打标签 - for (int i = 0; i < 10; i++) { - if (i == 7){ - System.out.println("Test"); - break outer; //执行break时,会直接跳出整个代码块,而不是第一个循环 - } - } - - System.out.println("???"); -} -``` - -虽然效果挺奇特的,但是一般情况下没人这么玩,所以说了解就行了。 - -前面我们介绍了for循环语句,我们接着来看第二种while循环,for循环要求我们填写三个表达式,而while相当于是一个简化版本,它只需要我们填写循环的维持条件即可,比如: - -```java -while(循环条件) 循环体; -``` - -相比for循环,while循环更多的用在不明确具体的结束时机的情况下,而for循环更多用于明确知道循环的情况,比如我们现在明确要进行循环10次,此时用for循环会更加合适一些,又比如我们现在只知道当`i`大于10时需要结束循环,但是`i`在循环多少次之后才不满足循环条件我们并不知道,此时使用while就比较合适了。 - -```java -public static void main(String[] args) { - int i = 100; //比如现在我们想看看i不断除以2得到的结果会是什么,但是循环次数我们并不明确 - while (i > 0) { //现在唯一知道的是循环条件,只要大于0那么就可以继续除 - System.out.println(i); - i /= 2; //每次循环都除以2 - } -} -``` - -上面的这种情况就非常适合使用while循环。 - -和for循环一样,while也支持使用break和continue来进行循环的控制,以及嵌套使用: - -```java -public static void main(String[] args) { - int i = 100; - while (i > 0) { - if(i < 10) break; - System.out.println(i); - i /= 2; - } -} -``` - -我们也可以反转循环判断的时机,可以先执行循环内容,然后再做循环条件判断,这里要用到`do-while`语句: - -```java -public static void main(String[] args) { - int i = 0; //比如现在我们想看看i不断除以2得到的结果会是什么,但是循环次数我们并不明确 - do { //无论满不满足循环条件,先执行循环体里面的内容 - System.out.println("Hello World!"); - i++; - } while (i < 10); //再做判断,如果判断成功,开启下一轮循环,否则结束 -} -``` - -至此,面向过程相关的内容就讲解完毕了,从下一章开始,我们将进入面向对象编程的学习(类、数组、字符串) - -*** - -## 实战练习 - -面向过程的内容全部学习完成,我们来做几个练习题吧! - -### 寻找水仙花数 - -> “水仙花数(Narcissistic number)也被称为超完全数字不变数(pluperfect digital invariant, PPDI)、自恋数、自幂数、阿姆斯壮数或阿姆斯特朗数(Armstrong number),水仙花数是指**一个 3 位数,它的每个位上的数字的 3次幂之和等于它本身。**例如:1^3 + 5^3+ 3^3 = 153。” - -现在请你设计一个Java程序,打印出所有1000以内的水仙花数。 - -```java -public class Main { - public static void main(String[] args) { - for (int i = 100; i < 1000; i++) { - int a = i % 10; - int b = i / 10 % 10; - int c = i / 100 % 10; - if (a * a * a + b * b * b + c * c * c == i) { - System.out.println(i + "是水仙花数"); - } - } - } -} -``` - - - -### 打印九九乘法表 - -![img](https://s2.loli.net/2022/09/18/zy1wuvj6gfHmAZS.jpg) - -现在我们要做的是在我们的程序中,也打印出这样的一个乘法表出来,请你设计一个Java程序来实现它。 - -![img](https://s2.loli.net/2022/09/18/Iek7OnbRoTw46Cl.jpg) - -```java -public class Main { - public static void main(String[] args) { - for (int i = 1; i <= 9; i++) { - for (int j = 1; j < 9; j++) { - if (j <= i) { - System.out.print(j + "*" + i + "=" + i * j + " "); - } - } - System.out.println(); - } - } -} -``` - - - -### 斐波那契数列 - -> 斐波那契数列(Fibonacci sequence),又称[黄金分割](https://baike.baidu.com/item/黄金分割/115896)数列,因数学家莱昂纳多·斐波那契(Leonardo Fibonacci)以兔子繁殖为例子而引入,故又称为“兔子数列”,指的是这样一个数列:**1、1、2、3、5、8、13、21、34、……**在数学上,斐波那契数列以如下被以递推的方法定义:*F*(0)=0,*F*(1)=1, *F*(n)=*F*(n - 1)+*F*(n - 2)(*n* ≥ 2,*n* ∈ N*)在现代物理、准[晶体结构](https://baike.baidu.com/item/晶体结构/10401467)、化学等领域,斐波纳契数列都有直接的应用,为此,美国数学会从 1963 年起出版了以《斐波纳契数列季刊》为名的一份数学杂志,用于专门刊载这方面的研究成果。 - -斐波那契数列:1,1,2,3,5,8,13,21,34,55,89...,不难发现一个规律,实际上从第三个数开始,每个数字的值都是前两个数字的和,现在请你设计一个Java程序,可以获取斐波那契数列上任意一位的数字,比如获取第5个数,那么就是5。 - -```java -public static void main(String[] args) { - int target = 7, result; //target是要获取的数,result是结果 - - //请在这里实现算法 - - System.out.println(result); -} -``` \ No newline at end of file diff --git a/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(五)重制版.md b/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(五)重制版.md deleted file mode 100644 index 840c2b6..0000000 --- a/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(五)重制版.md +++ /dev/null @@ -1,2068 +0,0 @@ - ![image-20220924223020333](https://s2.loli.net/2022/09/24/AulBzXWK6JCPMH5.png) - -# 泛型程序设计 - -在前面我们学习了最重要的类和对象,了解了面向对象编程的思想,注意,非常重要,面向对象是必须要深入理解和掌握的内容,不能草草结束。在本章节,我们还会继续深入了解,从泛型开始,再到数据结构,最后再开始我们的集合类学习,循序渐进。 - -## 泛型 - -为了统计学生成绩,要求设计一个Score对象,包括课程名称、课程号、课程成绩,但是成绩分为两种,一种是以`优秀、良好、合格` 来作为结果,还有一种就是 `60.0、75.5、92.5` 这样的数字分数,可能高等数学这门课是以数字成绩进行结算,而计算机网络实验这门课是以等级进行结算,这两种分数类型都有可能出现,那么现在该如何去设计这样的一个Score类呢? - -现在的问题就是,成绩可能是`String`类型,也可能是`Integer`类型,如何才能很好的去存可能出现的两种类型呢? - -```java -public class Score { - String name; - String id; - Object value; //因为Object是所有类型的父类,因此既可以存放Integer也能存放String - - public Score(String name, String id, Object value) { - this.name = name; - this.id = id; - this.score = value; - } -} -``` - -以上的方法虽然很好地解决了多种类型存储问题,但是Object类型在编译阶段并不具有良好的类型判断能力,很容易出现以下的情况: - -```java -public static void main(String[] args) { - - Score score = new Score("数据结构与算法基础", "EP074512", "优秀"); //是String类型的 - - ... - - Integer number = (Integer) score.score; //获取成绩需要进行强制类型转换,虽然并不是一开始的类型,但是编译不会报错 -} -``` - -使用Object类型作为引用,对于使用者来说,由于是Object类型,所以说并不能直接判断存储的类型到底是String还是Integer,取值只能进行强制类型转换,显然无法在编译期确定类型是否安全,项目中代码量非常之大,进行类型比较又会导致额外的开销和增加代码量,如果不经比较就很容易出现类型转换异常,代码的健壮性有所欠缺 - -所以说这种解决办法虽然可行,但并不是最好的方案。 - -为了解决以上问题,JDK 5新增了泛型,它能够在编译阶段就检查类型安全,大大提升开发效率。 - -### 泛型类 - -泛型其实就一个待定类型,我们可以使用一个特殊的名字表示泛型,泛型在定义时并不明确是什么类型,而是需要到使用时才会确定对应的泛型类型。 - -我们可以将一个类定义为一个泛型类: - -```java -public class Score { //泛型类需要使用<>,我们需要在里面添加1 - N个类型变量 - String name; - String id; - T value; //T会根据使用时提供的类型自动变成对应类型 - - public Score(String name, String id, T value) { //这里T可以是任何类型,但是一旦确定,那么就不能修改了 - this.name = name; - this.id = id; - this.value = value; - } -} -``` - -我们来看看这是如何使用的: - -```java -public static void main(String[] args) { - Score score = new Score("计算机网络", "EP074512", "优秀"); - //因为现在有了类型变量,在使用时同样需要跟上<>并在其中填写明确要使用的类型 - //这样我们就可以根据不同的类型进行选择了 - String value = score.value; //一旦类型明确,那么泛型就变成对应的类型了 - System.out.println(value); -} -``` - -泛型将数据类型的确定控制在了编译阶段,在编写代码的时候就能明确泛型的类型,如果类型不符合,将无法通过编译!因为是具体使用对象时才会明确具体类型,所以说静态方法中是不能用的: - -![image-20220927135128332](https://s2.loli.net/2022/09/27/RCqAhvMGzNwfH7J.png) - -只不过这里需要注意一下,我们在方法中使用待确定类型的变量时,因为此时并不明确具体是什么类型,那么默认会认为这个变量是一个Object类型的变量,因为无论具体类型是什么,一定是Object类的子类: - -![image-20220926235642963](https://s2.loli.net/2022/09/26/gkFs35US9rxo7f2.png) - -我们可以对其进行强制类型转换,但是实际上没多大必要: - -```java -public void test(T t){ - String str = (String) t; //都明确要用String了,那这里定义泛型不是多此一举吗 -} -``` - -因为泛型本身就是对某些待定类型的简单处理,如果都明确要使用什么类型了,那大可不必使用泛型。还有,不能通过这个不确定的类型变量就去直接创建对象和对应的数组: - -![image-20220927134825845](https://s2.loli.net/2022/09/27/RlHYhPSUJ5ICswG.png) - -注意,具体类型不同的泛型类变量,不能使用不同的变量进行接收: - -![image-20220925170746329](https://s2.loli.net/2022/09/25/jhekq9ZKHoiT2yI.png) - -如果要让某个变量支持引用确定了任意类型的泛型,那么可以使用`?`通配符: - -```java -public static void main(String[] args) { - Test test = new Test(); - test = new Test(); - Object o = test.value; //但是注意,如果使用通配符,那么由于类型不确定,所以说具体类型同样会变成Object -} -``` - -当然,泛型变量不止可以只有一个,如果需要使用多个的话,我们也可以定义多个: - -```java -public class Test { //多个类型变量使用逗号隔开 - public A a; - public B b; - public C c; -} -``` - -那么在使用时,就需要将这三种类型都进行明确指定: - -```java -public static void main(String[] args) { - Test test = new Test<>(); //使用钻石运算符可以省略其中的类型 - test.a = "lbwnb"; - test.b = 10; - test.c = '淦'; -} -``` - -是不是感觉好像还是挺简单的?只要是在类中,都可以使用类型变量: - -```java -public class Test{ - - private T value; - - public void setValue(T value) { - this.value = value; - } - - public T getValue() { - return value; - } -} -``` - -只不过,泛型只能确定为一个引用类型,基本类型是不支持的: - -```java -public class Test{ - public T value; -} -``` - -![image-20220926232135111](https://s2.loli.net/2022/09/26/TI6tWwj4vXFdenr.png) - -如果要存放基本数据类型的值,我们只能使用对应的包装类: - -```java -public static void main(String[] args) { - Test test = new Test<>(); -} -``` - -当然,如果是基本类型的数组,因为数组本身是引用类型,所以说是可以的: - -```java -public static void main(String[] args) { - Test test = new Test<>(); -} -``` - -通过使用泛型,我们就可以将某些不明确的类型在具体使用时再明确。 - -### 泛型与多态 - -不只是类,包括接口、抽象类,都是可以支持泛型的: - -```java -public interface Study { - T test(); -} -``` - -当子类实现此接口时,我们可以选择在实现类明确泛型类型,或是继续使用此泛型让具体创建的对象来确定类型: - -```java -public class Main { - public static void main(String[] args) { - A a = new A(); - Integer i = a.test(); - } - - static class A implements Study { - //在实现接口或是继承父类时,如果子类是一个普通类,那么可以直接明确对应类型 - @Override - public Integer test() { - return null; - } - } -} -``` - -或者是继续摆烂,依然使用泛型: - -```java -public class Main { - public static void main(String[] args) { - A a = new A<>(); - String i = a.test(); - } - - static class A implements Study { - //让子类继续为一个泛型类,那么可以不用明确 - @Override - public T test() { - return null; - } - } -} -``` - -继承也是同样的: - -```java -static class A { - -} - -static class B extends A { - -} -``` - -### 泛型方法 - -当然,类型变量并不是只能在泛型类中才可以使用,我们也可以定义泛型方法。 - -当某个方法(无论是是静态方法还是成员方法)需要接受的参数类型并不确定时,我们也可以使用泛型来表示: - -```java -public class Main { - public static void main(String[] args) { - String str = test("Hello World!"); - } - - private static T test(T t){ //在返回值类型前添加<>并填写泛型变量表示这个是一个泛型方法 - return t; - } -} -``` - -泛型方法会在使用时自动确定泛型类型,比如上我们定义的是类型T作为参数,同样的类型T作为返回值,实际传入的参数是一个字符串类型的值,那么T就会自动变成String类型,因此返回值也是String类型。 - -```java -public static void main(String[] args) { - String[] strings = new String[1]; - Main main = new Main(); - main.add(strings, "Hello"); - System.out.println(Arrays.toString(strings)); -} - -private void add(T[] arr, T t){ - arr[0] = t; -} -``` - -实际上泛型方法在很多工具类中也有,比如说Arrays的排序方法: - -```java -Integer[] arr = {1, 4, 5, 2, 6, 3, 0, 7, 9, 8}; -Arrays.sort(arr, new Comparator() { - //通过创建泛型接口的匿名内部类,来自定义排序规则,因为匿名内部类就是接口的实现类,所以说这里就明确了类型 - @Override - public int compare(Integer o1, Integer o2) { //这个方法会在执行排序时被调用(别人来调用我们的实现) - return 0; - } -}); -``` - -比如现在我们想要让数据从大到小排列,我们就可以自定义: - -```java -public static void main(String[] args) { - Integer[] arr = {1, 4, 5, 2, 6, 3, 0, 7, 9, 8}; - Arrays.sort(arr, new Comparator() { - @Override - public int compare(Integer o1, Integer o2) { //两个需要比较的数会在这里给出 - return o2 - o1; - //compare方法要求返回一个int来表示两个数的大小关系,大于0表示大于,小于0表示小于 - //这里直接o2-o1就行,如果o2比o1大,那么肯定应该排在前面,所以说返回正数表示大于 - } - }); - System.out.println(Arrays.toString(arr)); -} -``` - -因为我们前面学习了Lambda表达式,像这种只有一个方法需要实现的接口,直接安排了: - -```java -public static void main(String[] args) { - Integer[] arr = {1, 4, 5, 2, 6, 3, 0, 7, 9, 8}; - Arrays.sort(arr, (o1, o2) -> o2 - o1); //瞬间变一行,效果跟上面是一样的 - System.out.println(Arrays.toString(arr)); -} -``` - -包括数组复制方法: - -```java -public static void main(String[] args) { - String[] arr = {"AAA", "BBB", "CCC"}; - String[] newArr = Arrays.copyOf(arr, 3); //这里传入的类型是什么,返回的类型就是什么,也是用到了泛型 - System.out.println(Arrays.toString(newArr)); -} -``` - -因此,泛型实际上在很多情况下都能够极大地方便我们对于程序的代码设计。 - -### 泛型的界限 - -现在有一个新的需求,现在没有String类型的成绩了,但是成绩依然可能是整数,也可能是小数,这时我们不希望用户将泛型指定为除数字类型外的其他类型,我们就需要使用到泛型的上界定义: - -```java -public class Score { //设定类型参数上界,必须是Number或是Number的子类 - private final String name; - private final String id; - private final T value; - - public Score(String name, String id, T value) { - this.name = name; - this.id = id; - this.value = value; - } - - public T getValue() { - return value; - } -} -``` - -只需要在泛型变量的后面添加`extends`关键字即可指定上界,使用时,具体类型只能是我们指定的上界类型或是上界类型的子类,不得是其他类型。否则一律报错: - -![image-20220927000902574](https://s2.loli.net/2022/09/27/BAgmdCkDFL62V8H.png) - -实际上就像这样: - -![img](https://s2.loli.net/2022/09/27/rLnjHp73tdFSPUM.png) - -同样的,当我们在使用变量时,泛型通配符也支持泛型的界限: - -```java -public static void main(String[] args) { - Score score = new Score<>("数据结构与算法", "EP074512", 60); -} -``` - -那么既然泛型有上界,那么有没有下界呢?肯定的啊: - -![image-20220927002611032](https://s2.loli.net/2022/09/27/UJg7s41NC9Gn6fX.png) - -只不过下界仅适用于通配符,对于类型变量来说是不支持的。下界限定就像这样: - -![4aa52791-73f4-448f-bab3-9133ea85d850.jpg](https://s2.loli.net/2022/09/27/QFZNSCpnAmKG7qr.png) - -那么限定了上界后,我们再来使用这个对象的泛型成员,会变成什么类型呢? - -```java -public static void main(String[] args) { - Score score = new Score<>("数据结构与算法基础", "EP074512", 10); - Number o = score.getValue(); //可以看到,此时虽然使用的是通配符,但是不再是Object类型,而是对应的上界 -} -``` - -但是我们限定下界的话,因为还是有可能是Object,所以说依然是跟之前一样: - -```java -public static void main(String[] args) { - Score score = new Score<>("数据结构与算法基础", "EP074512", 10); - Object o = score.getValue(); -} -``` - -通过给设定泛型上限,我们就可以更加灵活地控制泛型的具体类型范围。 - -### 类型擦除 - -前面我们已经了解如何使用泛型,那么泛型到底是如何实现的呢,程序编译之后的样子是什么样的? - -```java -public abstract class A { - abstract T test(T t); -} -``` - -实际上在Java中并不是真的有泛型类型(为了兼容之前的Java版本)因为所有的对象都是属于一个普通的类型,一个泛型类型编译之后,实际上会直接使用默认的类型: - -```java -public abstract class A { - abstract Object test(Object t); //默认就是Object -} -``` - -当然,如果我们给类型变量设定了上界,那么会从默认类型变成上界定义的类型: - -```java -public abstract class A { //设定上界为Number - abstract T test(T t); -} -``` - -那么编译之后: - -```java -public abstract class A { - abstract Number test(Number t); //上界Number,因为现在只可能出现Number的子类 -} -``` - -因此,泛型其实仅仅是在编译阶段进行类型检查,当程序在运行时,并不会真的去检查对应类型,所以说哪怕是我们不去指定类型也可以直接使用: - -```java -public static void main(String[] args) { - Test test = new Test(); //对于泛型类Test,不指定具体类型也是可以的,默认就是原始类型 -} -``` - -只不过此时编译器会给出警告: - -![image-20220927131226728](https://s2.loli.net/2022/09/27/kVCIg3TilOuLFmj.png) - -同样的,由于类型擦除,实际上我们在使用时,编译后的代码是进行了强制类型转换的: - -```java -public static void main(String[] args) { - A a = new B(); - String i = a.test("10"); //因为类型A只有返回值为原始类型Object的方法 -} -``` - -实际上编译之后: - -```java -public static void main(String[] args) { - A a = new B(); - String i = (String) a.test("10"); //依靠强制类型转换完成的 -} -``` - -不过,我们思考一个问题,既然继承泛型类之后可以明确具体类型,那么为什么`@Override`不会出现错误呢?我们前面说了,重写的条件是需要和父类的返回值类型和形参一致,而泛型默认的原始类型是Object类型,子类明确后变为其他类型,这显然不满足重写的条件,但是为什么依然能编译通过呢? - -```java -public class B extends A{ - @Override - String test(String s) { - return null; - } -} -``` - -我们来看看编译之后长啥样: - -```java -// Compiled from "B.java" -public class com.test.entity.B extends com.test.entity.A { - public com.test.entity.B(); - java.lang.String test(java.lang.String); - java.lang.Object test(java.lang.Object); //桥接方法,这才是真正重写的方法,但是使用时会调用上面的方法 -} -``` - -通过反编译进行观察,实际上是编译器帮助我们生成了一个桥接方法用于支持重写: - -```java -public class B extends A { - - public Object test(Object obj) { //这才是重写的桥接方法 - return this.test((Integer) obj); //桥接方法调用我们自己写的方法 - } - - public String test(String str) { //我们自己写的方法 - return null; - } -} -``` - -类型擦除机制其实就是为了方便使用后面集合类(不然每次都要强制类型转换)同时为了向下兼容采取的方案。因此,泛型的使用会有一些限制: - -首先,在进行类型判断时,不允许使用泛型,只能使用原始类型: - -![image-20220927133232627](https://s2.loli.net/2022/09/27/q7DQ9lAweJLOFky.png) - -只能判断是不是原始类型,里面的具体类型是不支持的: - -```java -Test test = new Test<>(); -System.out.println(test instanceof Test); //在进行类型判断时,不允许使用泛型,只能使用原始类型 -``` - -还有,泛型类型是不支持创建参数化类型数组的: - -![image-20220927133611288](https://s2.loli.net/2022/09/27/7tK5APuSZovBLIc.png) - -要用只能用原始类型: - -```java -public static void main(String[] args) { - Test[] test = new Test[10]; //同样是因为类型擦除导致的,运行时可不会去检查具体类型是什么 -} -``` - -只不过只是把它当做泛型类型的数组还是可以用的: - -![image-20220927134335255](https://s2.loli.net/2022/09/27/upjWbyq9XC5FLDv.png) - -### 函数式接口 - -学习了泛型,我们来介绍一下再JDK 1.8中新增的函数式接口。 - -函数式接口就是JDK1.8专门为我们提供好的用于Lambda表达式的接口,这些接口都可以直接使用Lambda表达式,非常方便,这里我们主要介绍一下四个主要的函数式接口: - -**Supplier供给型函数式接口:**这个接口是专门用于供给使用的,其中只有一个get方法用于获取需要的对象。 - -```java -@FunctionalInterface //函数式接口都会打上这样一个注解 -public interface Supplier { - T get(); //实现此方法,实现供给功能 -} -``` - -比如我们要实现一个专门供给Student对象Supplier,就可以使用: - -```java -public class Student { - public void hello(){ - System.out.println("我是学生!"); - } -} -``` - -```java -//专门供给Student对象的Supplier -private static final Supplier STUDENT_SUPPLIER = Student::new; -public static void main(String[] args) { - Student student = STUDENT_SUPPLIER.get(); - student.hello(); -} -``` - -**Consumer消费型函数式接口:**这个接口专门用于消费某个对象的。 - -```java -@FunctionalInterface -public interface Consumer { - void accept(T t); //这个方法就是用于消费的,没有返回值 - - default Consumer andThen(Consumer after) { //这个方法便于我们连续使用此消费接口 - Objects.requireNonNull(after); - return (T t) -> { accept(t); after.accept(t); }; - } -} -``` - -使用起来也是很简单的: - -```java -//专门消费Student对象的Consumer -private static final Consumer STUDENT_CONSUMER = student -> System.out.println(student+" 真好吃!"); -public static void main(String[] args) { - Student student = new Student(); - STUDENT_CONSUMER.accept(student); -} -``` - -当然,我们也可以使用`andThen`方法继续调用: - -```java -public static void main(String[] args) { - Student student = new Student(); - STUDENT_CONSUMER //我们可以提前将消费之后的操作以同样的方式预定好 - .andThen(stu -> System.out.println("我是吃完之后的操作!")) - .andThen(stu -> System.out.println("好了好了,吃饱了!")) - .accept(student); //预定好之后,再执行 -} -``` - -这样,就可以在消费之后进行一些其他的处理了,使用很简洁的代码就可以实现: - -![image-20220927181706365](https://s2.loli.net/2022/09/27/Pu1jGzKNSvnV9YZ.png) - -**Function函数型函数式接口:**这个接口消费一个对象,然后会向外供给一个对象(前两个的融合体) - -```java -@FunctionalInterface -public interface Function { - R apply(T t); //这里一共有两个类型参数,其中一个是接受的参数类型,还有一个是返回的结果类型 - - default Function compose(Function before) { - Objects.requireNonNull(before); - return (V v) -> apply(before.apply(v)); - } - - default Function andThen(Function after) { - Objects.requireNonNull(after); - return (T t) -> after.apply(apply(t)); - } - - static Function identity() { - return t -> t; - } -} -``` - -这个接口方法有点多,我们一个一个来看,首先还是最基本的`apply`方法,这个是我们需要实现的: - -```java -//这里实现了一个简单的功能,将传入的int参数转换为字符串的形式 -private static final Function INTEGER_STRING_FUNCTION = Object::toString; -public static void main(String[] args) { - String str = INTEGER_STRING_FUNCTION.apply(10); - System.out.println(str); -} -``` - -我们可以使用`compose`将指定函数式的结果作为当前函数式的实参: - -```java -public static void main(String[] args) { - String str = INTEGER_STRING_FUNCTION - .compose((String s) -> s.length()) //将此函数式的返回值作为当前实现的实参 - .apply("lbwnb"); //传入上面函数式需要的参数 - System.out.println(str); -} -``` - -相反的,`andThen`可以将当前实现的返回值进行进一步的处理,得到其他类型的值: - -```java -public static void main(String[] args) { - Boolean str = INTEGER_STRING_FUNCTION - .andThen(String::isEmpty) //在执行完后,返回值作为参数执行andThen内的函数式,最后得到的结果就是最终的结果了 - .apply(10); - System.out.println(str); -} -``` - -比较有趣的是,Function中还提供了一个将传入参数原样返回的实现: - -```java -public static void main(String[] args) { - Function function = Function.identity(); //原样返回 - System.out.println(function.apply("不会吧不会吧,不会有人听到现在还是懵逼的吧")); -} -``` - -**Predicate断言型函数式接口:**接收一个参数,然后进行自定义判断并返回一个boolean结果。 - -```java -@FunctionalInterface -public interface Predicate { - boolean test(T t); //这个方法就是我们要实现的 - - default Predicate and(Predicate other) { - Objects.requireNonNull(other); - return (t) -> test(t) && other.test(t); - } - - default Predicate negate() { - return (t) -> !test(t); - } - - default Predicate or(Predicate other) { - Objects.requireNonNull(other); - return (t) -> test(t) || other.test(t); - } - - static Predicate isEqual(Object targetRef) { - return (null == targetRef) - ? Objects::isNull - : object -> targetRef.equals(object); - } -} -``` - -我们可以来编写一个简单的例子: - -```java -public class Student { - public int score; -} -``` - -```java -private static final Predicate STUDENT_PREDICATE = student -> student.score >= 60; -public static void main(String[] args) { - Student student = new Student(); - student.score = 80; - if(STUDENT_PREDICATE.test(student)) { //test方法的返回值是一个boolean结果 - System.out.println("及格了,真不错,今晚奖励自己一次"); - } else { - System.out.println("不是,Java都考不及格?隔壁初中生都在打ACM了"); - } -} -``` - -我们也可以使用组合条件判断: - -```java -public static void main(String[] args) { - Student student = new Student(); - student.score = 80; - boolean b = STUDENT_PREDICATE - .and(stu -> stu.score > 90) //需要同时满足这里的条件,才能返回true - .test(student); - if(!b) System.out.println("Java到现在都没考到90分?你的室友都拿国家奖学金了"); -} -``` - -同样的,这个类型提供了一个对应的实现,用于判断两个对象是否相等: - -```java -public static void main(String[] args) { - Predicate predicate = Predicate.isEqual("Hello World"); //这里传入的对象会和之后的进行比较 - System.out.println(predicate.test("Hello World")); -} -``` - -通过使用这四个核心的函数式接口,我们就可以使得代码更加简洁,具体的使用场景会在后面讲解。 - -### 判空包装 - -Java8还新增了一个非常重要的判空包装类Optional,这个类可以很有效的处理空指针问题。 - -比如对于下面这样一个很简单的方法: - -```java -private static void test(String str){ //传入字符串,如果不是空串,那么就打印长度 - if(!str.isEmpty()) { - System.out.println("字符串长度为:"+str.length()); - } -} -``` - -但是如果我们在传入参数时,丢个null进去,直接原地爆炸: - -```java -public static void main(String[] args) { - test(null); -} - -private static void test(String str){ - if(!str.isEmpty()) { //此时传入的值为null,调用方法马上得到空指针异常 - System.out.println("字符串长度为:"+str.length()); - } -} -``` - -因此我们还需要在使用之前进行判空操作: - -```java -private static void test(String str){ - if(str == null) return; //这样就可以防止null导致的异常了 - if(!str.isEmpty()) { - System.out.println("字符串长度为:"+str.length()); - } -} -``` - -虽然这种方式很好,但是在Java8之后,有了Optional类,它可以更加优雅地处理这种问题,我们来看看如何使用: - -```java -private static void test(String str){ - Optional - .ofNullable(str) //将传入的对象包装进Optional中 - .ifPresent(s -> System.out.println("字符串长度为:"+s.length())); - //如果不为空,则执行这里的Consumer实现 -} -``` - -优雅,真是太优雅了,同样的功能,现在我们只需要两行就搞定了,而且代码相当简洁。如果你学习过JavaScript或是Kotlin等语言,它的语法就像是: - -```kotlin -var str : String? = null -str?.upperCase() -``` - -并且,包装之后,我们再获取时可以优雅地处理为空的情况: - -```java -private static void test(String str){ - String s = Optional.ofNullable(str).get(); //get方法可以获取被包装的对象引用,但是如果为空的话,会抛出异常 - System.out.println(s); -} -``` - -我们可以对于这种有可能为空的情况进行处理,如果为空,那么就返回另一个备选方案: - -```java -private static void test(String str){ - String s = Optional.ofNullable(str).orElse("我是为null的情况备选方案"); - System.out.println(s); -} -``` - -是不是感觉很方便?我们还可以将包装的类型直接转换为另一种类型: - -```java -private static void test(String str){ - Integer i = Optional - .ofNullable(str) - .map(String::length) //使用map来进行映射,将当前类型转换为其他类型,或者是进行处理 - .orElse(-1); - System.out.println(i); -} -``` - -当然,Optional的方法比较多,这里就不一一介绍了。 - -*** - -## 数据结构基础 - -**注意:**本部分内容难度很大,推荐计算机专业课程《数据结构与算法》作为前置学习课程。本部分介绍数据结构只是为了为后面的集合类型做准备。 - -学习集合类之前,我们还有最关键的内容需要学习,同第二章一样,自底向上才是最佳的学习方向,比起直接带大家认识集合类,不如先了解一下数据结构,只有了解了数据结构基础,才能更好地学习集合类,同时,数据结构也是你以后深入学习JDK源码的必备条件(学习不要快餐式)当然,我们主要是讲解Java,数据结构作为铺垫作用,所以我们只会讲解关键的部分,其他部分可以在数据结构与算法篇视频教程中详细学习。 - -> 在计算机科学中,数据结构是一种数据组织、管理和存储的格式,它可以帮助我们实现对数据高效的访问和修改。更准确地说,数据结构是数据值的集合,可以体现数据值之间的关系,以及可以对数据进行应用的函数或操作。 - -通俗地说,我们需要去学习在计算机中如何去更好地管理我们的数据,才能让我们对我们的数据控制更加灵活! - -![image-20220710103307583](https://s2.loli.net/2022/07/10/9RwL7pxgyfoB3WT.png) - -比如现在我们需要保存100个学生的数据,那么你首先想到的肯定是使用数组吧!没错,没有什么比数组更适合存放这100个学生的数据了,但是如果我们现在有了新的需求呢?我们不仅仅是存放这些数据,我们还希望能够将这些数据按顺序存放,支持在某个位置插入一条数据、删除一条数据、修改一条数据等,这时候,数组就显得有些乏力了。 - -数组无法做到这么高级的功能,那么我们就需要定义一种更加高级的数据结构来做到,我们可以使用线性表(Linear List) - -> 线性表是由同一类型的数据元素构成的有序序列的线性结构。线性表中元素的个数就是线性表的长度,表的起始位置称为表头,表的结束位置称为表尾,当一个线性表中没有元素时,称为空表。 - -线性表一般需要包含以下功能: - -* **获取指定位置上的元素:**直接获取线性表指定位置`i`上的元素。 -* **插入元素:**在指定位置`i`上插入一个元素。 -* **删除元素:**删除指定位置`i`上的一个元素。 -* **获取长度:**返回线性表的长度。 - -也就是说,现在我们需要设计的是一种功能完善的表结构,它不像是数组那么低级,而是真正意义上的表: - -![image-20220723112639416](https://s2.loli.net/2022/07/23/Ve6dlqROzhumD5o.png) - -简单来说它就是列表,比如我们的菜单,我们在点菜时就需要往菜单列表中添加菜品或是删除菜品,这时列表就很有用了,因为数组长度固定、操作简单,而我们添加菜品、删除菜品这些操作又要求长度动态变化、操作多样。 - -那么,如此高级的数据结构,我们该如何去实现呢?实现线性表的结构一般有两种,一种是顺序存储实现,还有一种是链式存储实现,我们先来看第一种,也是最简单的的一种。 - -### 线性表:顺序表 - -前面我们说到,既然数组无法实现这样的高级表结构,那么我就基于数组,对其进行强化,也就是说,我们存放数据还是使用数组,但是我们可以为其编写一些额外的操作来强化为线性表,像这样底层依然采用顺序存储实现的线性表,我们称为顺序表。 - -![image-20220724150015044](https://s2.loli.net/2022/07/24/elBvx4Zo1AJ2WqT.png) - -这里我们可以先定义一个新的类型: - -```java -public class ArrayList { //泛型E,因为表中要存的具体数据类型待定 - int capacity = 10; //当前顺序表的容量 - int size = 0; //当前已经存放的元素数量 - private Object[] array = new Object[capacity]; //底层存放数据的数组 -} -``` - -顺序表的插入和删除操作,其实就是: - -![67813f22-3607-4351-934d-f8127e6ba15a](https://s2.loli.net/2022/09/27/24Glc7UQjLt5Wny.jpg) - -当插入元素时,需要将插入位置给腾出来,也就是将后面的所有元素向后移,同样的,如果要删除元素,那么也需要将所有的元素向前移动,顺序表是紧凑的,不能出现空位。 - -所以说我们可以来尝试实现一下,首先是插入方法: - -```java -public void add(E element, int index){ //插入方法需要支持在指定下标位置插入 - for (int i = size; i > index; i--) //从后往前,一个一个搬运元素 - array[i] = array[i - 1]; - array[index] = element; //腾出位置之后,直接插入元素放到对应位置上 - size++; //插入完成之后,记得将size自增 -} -``` - -只不过这样并不完美,因为我们的插入操作并不是在任何位置都支持插入的,我们允许插入的位置只能是 [0, size] 这个范围内 - -![image-20220723153933279](https://s2.loli.net/2022/07/23/H67F1crBhqQiXxg.png) - -所以说我们需要在插入之前进行判断: - -```java -public void add(E element, int index){ - if(index < 0 || index > size) //插入之前先判断插入位置是否合法 - throw new IndexOutOfBoundsException("插入位置非法,合法的插入位置为:0 ~ "+size); - for (int i = size; i > index; i--) - array[i] = array[i - 1]; - array[index] = element; - size++; -} -``` - -我们来测试一下吧: - -```java -public static void main(String[] args) { - ArrayList list = new ArrayList<>(); - list.add(10, 1); //一上来只能在第一个位置插入,第二个位置肯定是非法的 -} -``` - -于是就成功得到异常: - -![image-20220927211134905](https://s2.loli.net/2022/09/27/rtkRMaWseE2Cm1z.png) - -只不过依然不够完美,万一我们的顺序表装满了咋办?所以说,我们在插入元素之前,需要进行判断,如果已经装满了,那么我们需要先扩容之后才能继续插入新的元素: - -```java -public void add(E element, int index){ - if(index < 0 || index > size) - throw new IndexOutOfBoundsException("插入位置非法,合法的插入位置为:0 ~ "+size); - if(capacity == size) { - int newCapacity = capacity + (capacity >> 1); //扩容规则就按照原本容量的1.5倍来吧 - Object[] newArray = new Object[newCapacity]; //创建一个新的数组来存放更多的元素 - System.arraycopy(array, 0, newArray, 0, size); //使用arraycopy快速拷贝原数组内容到新的数组 - array = newArray; //更换为新的数组 - capacity = newCapacity; //容量变成扩容之后的 - } - for (int i = size; i > index; i--) - array[i] = array[i - 1]; - array[index] = element; - size++; -} -``` - -我们来重写一下`toString`方法打印当前存放的元素: - -```java -public String toString() { - StringBuilder builder = new StringBuilder(); - for (int i = 0; i < size; i++) builder.append(array[i]).append(" "); - return builder.toString(); -} -``` - -可以看到,我们的底层数组会自动扩容,便于我们使用: - -```java -public static void main(String[] args) { - ArrayList list = new ArrayList<>(); - for (int i = 0; i < 20; i++) - list.add(i, i); - System.out.println(list); -} -``` - -![image-20220927212426959](https://s2.loli.net/2022/09/27/6SMZxC5QI3cgXYk.png) - -我们接着来看删除操作,其实操作差不多,只需要将后面的覆盖到前面就可以了: - -```java -@SuppressWarnings("unchecked") //屏蔽未经检查警告 -public E remove(int index){ //删除对应位置上的元素,注意需要返回被删除的元素 - E e = (E) array[index]; //因为存放的是Object类型,这里需要强制类型转换为E - for (int i = index; i < size; i++) //从前往后,挨个往前搬一位 - array[i] = array[i + 1]; - size--; //删完记得将size-- - return e; -} -``` - -同样的,我们需要对删除的合法范围进行判断: - -![image-20220723160901921](https://s2.loli.net/2022/07/23/uHBjUfKpd9ygScW.png) - -所以说我们也来进行一下判断: - -```java -@SuppressWarnings("unchecked") -public E remove(int index){ - if(index < 0 || index > size - 1) - throw new IndexOutOfBoundsException("删除位置非法,合法的插入位置为:0 ~ "+(size - 1)); - E e = (E) array[index]; - for (int i = index; i < size; i++) - array[i] = array[i + 1]; - size--; - return e; -} -``` - -因为删除不需要考虑容量的问题,所以说这里的删除操作就编写完成了。 - -当然,我们还得支持获取指定下标位置上的元素,这个就简单了,直接从数组中那就行了: - -```java -@SuppressWarnings("unchecked") -public E get(int index){ - if(index < 0 || index > size - 1) //在插入之前同样要进行范围检查 - throw new IndexOutOfBoundsException("非法的位置,合法的位置为:0 ~ "+(size - 1)); - return (E) array[index]; //直接返回就完事 -} - -public int size(){ //获取当前存放的元素数量 - return size; -} -``` - -是不是感觉顺便表其实还是挺简单的,也就是一个数组多了一些操作罢了。 - -### 线性表:链表 - -前面我们介绍了如何使用数组实现线性表,我们接着来看第二种方式,我们可以使用链表来实现,那么什么是链表呢? - -![image-20220723171648380](https://s2.loli.net/2022/07/23/ruemiRQplVy7q9s.png) - -链表不同于顺序表,顺序表底层采用数组作为存储容器,需要分配一块连续且完整的内存空间进行使用,而链表则不需要,它通过一个指针来连接各个分散的结点,形成了一个链状的结构,每个结点存放一个元素,以及一个指向下一个结点的指针,通过这样一个一个相连,最后形成了链表。它不需要申请连续的空间,只需要按照顺序连接即可,虽然物理上可能不相邻,但是在逻辑上依然是每个元素相邻存放的,这样的结构叫做链表(单链表)。 - -链表分为带头结点的链表和不带头结点的链表,戴头结点的链表就是会有一个头结点指向后续的整个链表,但是头结点不存放数据: - -![image-20220723180221112](https://s2.loli.net/2022/07/23/gRUEfOqbtrGN2JZ.png) - -而不带头结点的链表就像上面那样,第一个节点就是存放数据的结点,一般设计链表都会采用带头结点的结构,因为操作更加方便。 - -我们来尝试定义一下: - -```java -public class LinkedList { - //链表的头结点,用于连接之后的所有结点 - private final Node head = new Node<>(null); - private int size = 0; //当前的元素数量还是要存一下,方便后面操作 - - private static class Node { //结点类,仅供内部使用 - E element; //每个结点都存放元素 - Node next; //以及指向下一个结点的引用 - - public Node(E element) { - this.element = element; - } - } -} -``` - -接着我们来设计一下链表的插入和删除,我们前面实现了顺序表的插入,那么链表的插入该怎么做呢? - -![image-20220723175548491](https://s2.loli.net/2022/07/23/71dgFSWDfoELiXB.png) - -我们可以先修改新插入的结点的后继结点(也就是下一个结点)指向,指向原本在这个位置的结点: - -![image-20220723220552680](https://s2.loli.net/2022/07/23/8MNURYiacWZqwu6.png) - -接着我们可以将前驱结点(也就是上一个结点)的后继结点指向修改为我们新插入的结点: - -![image-20220723175745472](https://s2.loli.net/2022/07/23/ysETUJb6cgBz2Qx.png) - -这样,我们就成功插入了一个新的结点,现在新插入的结点到达了原本的第二个位置上: - -![image-20220723175842075](https://s2.loli.net/2022/07/23/Kb7jCiWa3o4AN8D.png) - -按照这个思路,我们来实现一下,首先设计一下方法: - -```java -public void add(E element, int index){ - Node prev = head; //先找到对应位置的前驱结点 - for (int i = 0; i < index; i++) - prev = prev.next; - Node node = new Node<>(element); //创建新的结点 - node.next = prev.next; //先让新的节点指向原本在这个位置上的结点 - prev.next = node; //然后让前驱结点指向当前结点 - size++; //完事之后一样的,更新size -} -``` - -我们来重写一下toString方法看看能否正常插入: - -```java -@Override -public String toString() { - StringBuilder builder = new StringBuilder(); - Node node = head.next; //从第一个结点开始,一个一个遍历,遍历一个就拼接到字符串上去 - while (node != null) { - builder.append(node.element).append(" "); - node = node.next; - } - return builder.toString(); -} -``` - -可以看到我们的插入操作是可以正常工作的: - -```java -public static void main(String[] args) { - LinkedList list = new LinkedList<>(); - list.add(10, 0); - list.add(30, 0); - list.add(20, 1); - System.out.println(list); -} -``` - -![image-20220927235051844](https://s2.loli.net/2022/09/27/Mpj9azwWciemAZY.png) - -只不过还不够完美,跟之前一样,我们还得考虑插入位置是否合法: - -```java -public void add(E element, int index){ - if(index < 0 || index > size) - throw new IndexOutOfBoundsException("插入位置非法,合法的插入位置为:0 ~ "+size); - Node prev = head; - for (int i = 0; i < index; i++) - prev = prev.next; - Node node = new Node<>(element); - node.next = prev.next; - prev.next = node; - size++; -} -``` - -插入操作完成之后,我们接着来看删除操作,那么我们如何实现删除操作呢?实际上也会更简单一些,我们可以直接将待删除节点的前驱结点指向修改为待删除节点的下一个: - -![image-20220723222922058](https://s2.loli.net/2022/07/23/N5sZx9T2a8lOzoC.png) - -![image-20220723223103306](https://s2.loli.net/2022/07/23/tNYnBJe9pczUq1Z.png) - -这样,在逻辑上来说,待删除结点其实已经不在链表中了,所以我们只需要释放掉待删除结点占用的内存空间就行了: - -![image-20220723223216420](https://s2.loli.net/2022/07/23/MFE2gZuS5eOysDW.png) - -那么我们就按照这个思路来编写一下程序: - -```java -public E remove(int index){ - if(index < 0 || index > size - 1) //同样的,先判断位置是否合法 - throw new IndexOutOfBoundsException("删除位置非法,合法的删除位置为:0 ~ "+(size - 1)); - Node prev = head; - for (int i = 0; i < index; i++) //同样需要先找到前驱结点 - prev = prev.next; - E e = prev.next.element; //先把待删除结点存放的元素取出来 - prev.next = prev.next.next; //可以删了 - size--; //记得size-- - return e; -} -``` - -是不是感觉还是挺简单的?这样,我们就成功完成了链表的删除操作。 - -我们接着来实现一下获取对应位置上的元素: - -```java -public E get(int index){ - if(index < 0 || index > size - 1) - throw new IndexOutOfBoundsException("非法的位置,合法的位置为:0 ~ "+(size - 1)); - Node node = head; - while (index-- >= 0) //这里直接让index减到-1为止 - node = node.next; - return node.element; -} - -public int size(){ - return size; -} -``` - -这样,我们的链表就编写完成了,实际上只要理解了那种结构,其实还是挺简单的。 - -**问题**:什么情况下使用顺序表,什么情况下使用链表呢? - -* 通过分析顺序表和链表的特性我们不难发现,链表在随机访问元素时,需要通过遍历来完成,而顺序表则利用数组的特性直接访问得到,所以,当我们读取数据多于插入或是删除数据的情况下时,使用顺序表会更好。 -* 而顺序表在插入元素时就显得有些鸡肋了,因为需要移动后续元素,整个移动操作会浪费时间,而链表则不需要,只需要修改结点 指向即可完成插入,所以在频繁出现插入或删除的情况下,使用链表会更好。 - -虽然单链表使用起来也比较方便,不过有一个问题就是,如果我们想要操作某一个结点,比如删除或是插入,那么由于单链表的性质,我们只能先去找到它的前驱结点,才能进行。为了解决这种查找前驱结点非常麻烦的问题,我们可以让结点不仅保存指向后续结点的指针,同时也保存指向前驱结点的指针: - -![image-20220724123947104](https://s2.loli.net/2022/07/24/oeXm6nyW7I9lPMf.png) - -这样我们无论在哪个结点,都能够快速找到对应的前驱结点,就很方便了,这样的链表我们成为双向链表(双链表) - -### 线性表:栈 - -栈(也叫堆栈,Stack)是一种特殊的线性表,它只能在在表尾进行插入和删除操作,就像下面这样: - -![image-20220724210955622](https://s2.loli.net/2022/07/24/D3heysaM9EpAgS4.png) - -也就是说,我们只能在一端进行插入和删除,当我们依次插入1、2、3、4这四个元素后,连续进行四次删除操作,删除的顺序刚好相反:4、3、2、1,我们一般将其竖着看: - -![image-20220724211442421](https://s2.loli.net/2022/07/24/2NxUpCIRLoZt9Ky.png) - -底部称为栈底,顶部称为栈顶,所有的操作只能在栈顶进行,也就是说,被压在下方的元素,只能等待其上方的元素出栈之后才能取出,就像我们往箱子里里面放的书一样,因为只有一个口取出里面的物品,所以被压在下面的书只能等上面的书被拿出来之后才能取出,这就是栈的思想,它是一种先进后出的数据结构(FILO,First In, Last Out) - -实现栈也是非常简单的,可以基于我们前面的顺序表或是链表,这里我们需要实现两个新的操作: - -* pop:出栈操作,从栈顶取出一个元素。 -* push:入栈操作,向栈中压入一个新的元素。 - -栈可以使用顺序表实现,也可以使用链表实现,这里我们就使用链表,实际上使用链表会更加的方便,我们可以直接将头结点指向栈顶结点,而栈顶结点连接后续的栈内结点: - -![image-20220724222836333](https://s2.loli.net/2022/07/24/outf2S7D3WzQK8c.png) - -当有新的元素入栈,只需要在链表头部插入新的结点即可,我们来尝试编写一下: - -```java -public class LinkedStack { - - private final Node head = new Node<>(null); //大体内容跟链表类似 - - private static class Node { - E element; - Node next; - - public Node(E element) { - this.element = element; - } - } -} -``` - -接着我们来编写一下入栈操作: - -![image-20220724223550553](https://s2.loli.net/2022/07/24/GdBj3g5YRFzSsVw.png) - -代码如下: - -```java -public void push(E element){ - Node node = new Node<>(element); //直接创建新结点 - node.next = head.next; //新结点的下一个变成原本的栈顶结点 - head.next = node; //头结点的下一个改成新的结点 -} -``` - -这样,我们就可以轻松实现入栈操作了。其实出栈也是同理,所以我们只需要将第一个元素移除即可: - -```java -public E pop(){ - if(head.next == null) //如果栈已经没有元素了,那么肯定是没办法取的 - throw new NoSuchElementException("栈为空"); - E e = head.next.element; //先把待出栈元素取出来 - head.next = head.next.next; //直接让头结点的下一个指向下一个的下一个 - return e; -} -``` - -我们来测试一下吧: - -```java -public static void main(String[] args) { - LinkedStack stack = new LinkedStack<>(); - stack.push("AAA"); - stack.push("BBB"); - stack.push("CCC"); - System.out.println(stack.pop()); - System.out.println(stack.pop()); - System.out.println(stack.pop()); -} -``` - -可以看到,入栈顺序和出栈顺序是完全相反的: - -![image-20220928101152179](https://s2.loli.net/2022/09/28/yaWmfPDU63X8BQn.png) - -其实还是挺简单的。 - -### 线性表:队列 - -前面我们学习了栈,栈中元素只能栈顶出入,它是一种特殊的线性表,同样的,队列(Queue)也是一种特殊的线性表。 - -就像我们在超市、食堂需要排队一样,我们总是排成一列,先到的人就排在前面,后来的人就排在后面,越前面的人越先完成任务,这就是队列,队列有队头和队尾: - -![image-20220725103600318](https://s2.loli.net/2022/07/25/xBuZckTNtR54AEq.png) - -秉承先来后到的原则,队列中的元素只能从队尾进入,只能从队首出去,也就是说,入队顺序为1、2、3、4,那么出队顺序也一定是1、2、3、4,所以队列是一种先进先出(FIFO,First In, First Out)的数据结构。 - -队列也可以使用链表和顺序表来实现,只不过使用链表的话就不需要关心容量之类的问题了,会更加灵活一些: - -![image-20220725145214955](https://s2.loli.net/2022/07/25/lwGgHXqAV5z2KNk.png) - -注意我们需要同时保存队首和队尾两个指针,因为是单链表,所以队首需要存放指向头结点的指针,因为需要的是前驱结点,而队尾则直接是指向尾结点的指针即可,后面只需要直接在后面拼接就行。 - -当有新的元素入队时,只需要拼在队尾就行了,同时队尾指针也要后移一位: - -![image-20220725145608827](https://s2.loli.net/2022/07/25/ufmFEwrS9xVKoIZ.png) - -出队时,只需要移除队首指向的下一个元素即可: - -![image-20220725145707707](https://s2.loli.net/2022/07/25/geJRFwHKhGT69XD.png) - -那么我们就按照这个思路,来编写一下代码吧: - -```java -public class LinkedQueue { - - private final Node head = new Node<>(null); - - public void offer(E element){ //入队操作 - Node last = head; - while (last.next != null) //入队直接丢到最后一个结点的屁股后面就行了 - last = last.next; - last.next = new Node<>(element); - } - - public E poll(){ //出队操作 - if(head.next == null) //如果队列已经没有元素了,那么肯定是没办法取的 - throw new NoSuchElementException("队列为空"); - E e = head.next.element; - head.next = head.next.next; //直接从队首取出 - return e; - } - - private static class Node { - E element; - Node next; - - public Node(E element) { - this.element = element; - } - } -} -``` - -其实使用起来还是挺简单的,我们来测试一下吧: - -```java -public static void main(String[] args) { - LinkedQueue stack = new LinkedQueue<>(); - stack.offer("AAA"); - stack.offer("BBB"); - stack.offer("CCC"); - System.out.println(stack.poll()); - System.out.println(stack.poll()); - System.out.println(stack.poll()); -} -``` - -![image-20220928154121872](https://s2.loli.net/2022/09/28/FUS1Rc8JuEMT6bq.png) - -可以看到,队列遵从先进先出,入队顺序和出队顺序是一样的。 - -### 树:二叉树 - -树是一种全新的数据结构,它就像一棵树的树枝一样,不断延伸。 - -![树枝666](https://s2.loli.net/2022/08/08/NajFZzXHxUCDQBW.png) - -在我们的程序中,想要表示出一棵树,就可以像下面这样连接: - -![image-20220801210920230](https://s2.loli.net/2022/08/01/aoBjrR5bPqWzCel.png) - -可以看到,现在一个结点下面可能会连接多个节点,并不断延伸,就像树枝一样,每个结点都有可能是一个分支点,延伸出多个分支,从位于最上方的结点开始不断向下,而这种数据结构,我们就称为**树**(Tree)注意分支只能向后单独延伸,之后就分道扬镳了,**不能与其他分支上的结点相交!** - -* 我们一般称位于最上方的结点为树的**根结点**(Root)因为整棵树正是从这里开始延伸出去的。 -* 每个结点连接的子结点数目(分支的数目),我们称为结点的**度**(Degree),而各个结点度的最大值称为树的度。 -* 每个结点延伸下去的下一个结点都可以称为一棵**子树**(SubTree)比如结点`B`及其之后延伸的所有分支合在一起,就是一棵`A`的子树。 -* 每个**结点的层次**(Level)按照从上往下的顺序,树的根结点为`1`,每向下一层`+1`,比如`G`的层次就是`3`,整棵树中所有结点的最大层次,就是这颗**树的深度**(Depth),比如上面这棵树的深度为4,因为最大层次就是4。 - -由于整棵树错综复杂,所以说我们需要先规定一下结点之间的称呼,就像族谱那样: - -* 与当前结点直接向下相连的结点,我们称为**子结点**(Child),比如`B、C、D`结点,都是`A`的子结点,就像族谱中的父子关系一样,下一代一定是子女,相反的,那么`A`就是`B、C、D`的**父结点**(Parent),也可以叫双亲结点。 -* 如果某个节点没有任何的子结点(结点度为0时)那么我们称这个结点为**叶子结点**(因为已经到头了,后面没有分支了,这时就该树枝上长叶子了那样)比如`K、L、F、G、M、I、J`结点,都是叶子结点。 -* 如果两个结点的父结点是同一个,那么称这两个节点为**兄弟结点**(Sibling)比如`B`和`C`就是兄弟结点,因为都是`A`的孩子。 -* 从根结点开始一直到某个结点的整条路径的所有结点,都是这个结点的**祖先结点**(Ancestor)比如`L`的祖先结点就是`A、B、E` - -那么在了解了树的相关称呼之后,相信各位就应该对树有了一定的了解,虽然概念比较多,但是还请各位一定记住,不然后面就容易听懵。 - -而我们本章需要着重讨论的是**二叉树**(Binary Tree)它是一种特殊的树,它的度最大只能为`2`,所以我们称其为二叉树,一棵二叉树大概长这样: - -![image-20220801224008266](https://s2.loli.net/2022/08/01/QGLfnYWFby37deP.png) - -并且二叉树任何结点的子树是有左右之分的,不能颠倒顺序,比如A结点左边的子树,称为左子树,右边的子树称为右子树。 - -当然,对于某些二叉树我们有特别的称呼,比如,在一棵二叉树中,所有分支结点都存在左子树和右子树,且叶子结点都在同一层: - -![image-20220801231216578](https://s2.loli.net/2022/08/01/btfjlJhDuWrSXYi.png) - -这样的二叉树我们称为**满二叉树**,可以看到整棵树都是很饱满的,没有出现任何度为1的结点,当然,还有一种特殊情况: - -![image-20220801224008266](https://s2.loli.net/2022/08/01/QGLfnYWFby37deP.png) - -可以看到只有最后一层有空缺,并且所有的叶子结点是按照从左往右的顺序排列的,这样的二叉树我们一般称其为**完全二叉树**,所以,一棵满二叉树,一定是一棵完全二叉树。 - -我们接着来看看二叉树在程序中的表示形式,我们在前面使用链表的时候,每个结点不仅存放对应的数据,而且会存放一个指向下一个结点的引用: - -![image-20220723171648380](https://s2.loli.net/2022/07/23/ruemiRQplVy7q9s.png) - -而二叉树也可以使用这样的链式存储形式,只不过现在一个结点需要存放一个指向左子树的引用和一个指向右子树的引用了: - -![image-20220806111610082](https://s2.loli.net/2022/08/06/H9MqkghmAjFJnuO.png) - -通过这种方式,我们就可以通过连接不同的结点形成一颗二叉树了,这样也更便于我们去理解它,我们首先定义一个类: - -```java -public class TreeNode { - public E element; - public TreeNode left, right; - - public TreeNode(E element){ - this.element = element; - } -} -``` - -比如我们现在想要构建一颗像这样的二叉树: - -![image-20220805231744693](https://s2.loli.net/2022/08/05/uan6A3ZRLykt289.png) - -首先我们需要创建好这几个结点: - -```java -public static void main(String[] args) { - TreeNode a = new TreeNode<>('A'); - TreeNode b = new TreeNode<>('B'); - TreeNode c = new TreeNode<>('C'); - TreeNode d = new TreeNode<>('D'); - TreeNode e = new TreeNode<>('E'); - -} -``` - -接着我们从最上面开始,挨着进行连接,首先是A这个结点: - -```java -public static void main(String[] args) { - ... - a.left = b; - a.right = c; - b.left = d; - b.right = e; -} -``` - -这样的话,我们就成功构建好了这棵二叉树,比如现在我们想通过根结点访问到D: - -```java -System.out.println(a.left.left.element); -``` - -断点调试也可以看的很清楚: - -![image-20220930160452608](https://s2.loli.net/2022/09/30/XCkDxVBFz2bWph8.png) - -这样,我们就通过使用链式结构,成功构建出了一棵二叉树,接着我们来看看如何遍历一棵二叉树,也就是说我们想要访问二叉树的每一个结点,由于树形结构特殊,遍历顺序并不唯一,所以一共有四种访问方式:**前序遍历、中序遍历、后序遍历、层序遍历。**不同的访问方式输出都结点顺序也不同。 - -首先我们来看最简单的前序遍历: - -![image-20220806171459056](https://s2.loli.net/2022/08/06/G6ujstSVZ2XWJLE.png) - -前序遍历是一种勇往直前的态度,走到哪就遍历到那里,先走左边再走右边,比如上面的这个图,首先会从根节点开始: - -![image-20220806171431845](https://s2.loli.net/2022/08/06/qCFMosHtujEZ3U6.png) - -从A开始,先左后右,那么下一个就是B,然后继续走左边,是D,现在ABD走完之后,B的左边结束了,那么就要开始B的右边了,所以下一个是E,E结束之后,现在A的左子树已经全部遍历完成了,然后就是右边,接着就是C,C没有左子树了,那么只能走右边了,最后输出F,所以上面这个二叉树的前序遍历结果为:ABDECF - -1. 打印根节点 -2. 前序遍历左子树 -3. 前序遍历右子树 - -我们不难发现规律,整棵二叉树(包括子树)的根节点一定是出现在最前面的,比如A在最前面,A的左子树根结点B也是在最前面的。我们现在就来尝试编写一下代码实现一下,先把二叉树构建出来: - -```java -public static void main(String[] args) { - TreeNode a = new TreeNode<>('A'); - TreeNode b = new TreeNode<>('B'); - TreeNode c = new TreeNode<>('C'); - TreeNode d = new TreeNode<>('D'); - TreeNode e = new TreeNode<>('E'); - TreeNode f = new TreeNode<>('F'); - a.left = b; - a.right = c; - b.left = d; - b.right = e; - c.right = f; -} -``` - -组装好之后,我们来实现一下前序遍历的方法: - -```java -private static void preOrder(TreeNode root){ - System.out.print(root.element + " "); //首先肯定要打印,这个是必须的 -} -``` - -打印完成之后,我们就按照先左后右的规则往后遍历下一个结点,这里我们就直接使用递归来完成: - -```java -private static void preOrder(TreeNode root){ - System.out.print(root.element + " "); - preOrder(root.left); //先走左边 - preOrder(root.right); //再走右边 -} -``` - -不过还没完,我们的递归肯定是需要一个终止条件的,不可能无限地进行下去,如果已经走到底了,那么就不能再往下走了,所以: - -```java -private static void preOrder(TreeNode root){ - if(root == null) return; - System.out.print(root.element); - preOrder(root.left); - preOrder(root.right); -} -``` - -最后我们来测试一下吧: - -```java -public static void main(String[] args) { - ... - preOrder(a); -} -``` - -可以看到结果为: - -![image-20220806173227580](https://s2.loli.net/2022/08/06/hZ8qEfWaP5o6L2j.png) - -这样我们就通过一个简单的递归操作完成了对一棵二叉树的前序遍历,如果不太好理解,建议结合调试进行观察。 - -那么前序遍历我们了解完了,接着就是中序遍历了,中序遍历在顺序上与前序遍历不同,前序遍历是走到哪就打印到哪,而中序遍历需要先完成整个左子树的遍历后再打印,然后再遍历其右子树。 - -我们还是以上面的二叉树为例: - -![image-20220806230603967](https://s2.loli.net/2022/08/06/W6Yb5M92gQApNJa.png) - -首先需要先不断遍历左子树,走到最底部,但是沿途并不进行打印,而是到底之后,再打印,所以第一个打印的是D,接着由于没有右子树,所以我们回到B,此时再打印B,然后再去看B的右结点E,由于没有左子树和右子树了,所以直接打印E,左边遍历完成,接着回到A,打印A,然后对A的右子树重复上述操作。所以说遍历的基本规则还是一样的,只是打印值的时机发生了改变。 - -1. 中序遍历左子树 -2. 打印结点 -3. 中序遍历右子树 - -所以这棵二叉树的中序遍历结果为:DBEACF,我们可以发现一个规律,就是在某个结点的左子树中所有结点,其中序遍历结果也是按照这样的规律排列的,比如A的左子树中所有结点,中序遍历结果中全部都在A的左边,右子树中所有的结点,全部都在A的右边(这个规律很关键,后面在做一些算法题时会用到) - -那么怎么才能将打印调整到左子树全部遍历结束之后呢?其实很简单: - -```java -private static void inOrder(TreeNode root){ - if(root == null) return; - inOrder(root.left); //先完成全部左子树的遍历 - System.out.print(root.element); //等待左子树遍历完成之后再打印 - inOrder(root.right); //然后就是对右子树进行遍历 -} -``` - -我们只需要将打印放到左子树遍历之后即可,这样打印出来的结果就是中序遍历的结果了: - -![image-20220806231752418](https://s2.loli.net/2022/08/06/V2KdMy3T5Beo8vx.png) - -这样,我们就实现了二叉树的中序遍历,实际上还是很好理解的。 - -接着我们来看一下后序遍历,后序遍历继续将打印的时机延后,需要等待左右子树全部遍历完成,才会去进行打印。 - -![image-20220806233407910](https://s2.loli.net/2022/08/06/YE2rODdqpCInUa9.png) - -首先还是一路向左,到达结点D,此时结点D没有左子树了,接着看结点D还有没有右子树,发现也没有,左右子树全部遍历完成,那么此时再打印D,同样的,D完事之后就回到B了,此时接着看B的右子树,发现有结点E,重复上述操作,E也打印出来了,接着B的左右子树全部OK,那么再打印B,接着A的左子树就完事了,现在回到A,看到A的右子树,继续重复上述步骤,当A的右子树也遍历结束后,最后再打印A结点。 - -1. 后序遍历左子树 -2. 后序遍历右子树 -3. 打印结点 - -所以最后的遍历顺序为:DEBFCA,不难发现,整棵二叉树(包括子树)根结点一定是在后面的,比如A在所有的结点的后面,B在其子节点D、E的后面,这一点恰恰和前序遍历相反(注意不是得到的结果相反,是规律相反) - -所以,按照这个思路,我们来编写一下后序遍历: - -```java -private static void postOrder(TreeNode root){ - if(root == null) return; - postOrder(root.left); - postOrder(root.right); - System.out.print(root.element); //时机延迟到最后 -} -``` - -结果如下: - -![image-20220806234428922](https://s2.loli.net/2022/08/06/6Vx9fmSUcqw51Mp.png) - -最后我们来看层序遍历,实际上这种遍历方式是我们人脑最容易理解的,它是按照每一层在进行遍历: - -![image-20220807205135936](https://s2.loli.net/2022/08/07/ywF6r9MU1JSPIge.png) - -层序遍历实际上就是按照从上往下每一层,从左到右的顺序打印每个结点,比如上面的这棵二叉树,那么层序遍历的结果就是:ABCDEF,像这样一层一层的挨个输出。 - -虽然理解起来比较简单,但是如果让你编程写出来,该咋搞?是不是感觉有点无从下手? - -我们可以利用队列来实现层序遍历,首先将根结点存入队列中,接着循环执行以下步骤: - -* 进行出队操作,得到一个结点,并打印结点的值。 -* 将此结点的左右孩子结点依次入队。 - -不断重复以上步骤,直到队列为空。 - -我们来分析一下,首先肯定一开始A在里面: - -![image-20220807211522409](https://s2.loli.net/2022/08/07/ZsNpeVUivEjCymt.png) - -接着开始不断重复上面的步骤,首先是将队首元素出队,打印A,然后将A的左右孩子依次入队: - -![image-20220807211631110](https://s2.loli.net/2022/08/07/v8yXWNato3sfeUn.png) - -现在队列中有B、C两个结点,继续重复上述操作,B先出队,打印B,然后将B的左右孩子依次入队: - -![image-20220807211723776](https://s2.loli.net/2022/08/07/Qkprfi5RhAXP7Cd.png) - -现在队列中有C、D、E这三个结点,继续重复,C出队并打印,然后将F入队: - -![image-20220807211800852](https://s2.loli.net/2022/08/07/MxQTArlWK2gDjqi.png) - -我们发现,这个过程中,打印的顺序正好就是我们层序遍历的顺序,所以说队列还是非常有用的,这里我们可以直接把之前的队列拿来用。那么现在我们就来上代码吧,首先是之前的队列: - -```java -public class LinkedQueue { - - private final Node head = new Node<>(null); - - public void offer(E element){ - Node last = head; - while (last.next != null) - last = last.next; - last.next = new Node<>(element); - } - - public E poll(){ - if(head.next == null) - throw new NoSuchElementException("队列为空"); - E e = head.next.element; - head.next = head.next.next; - return e; - } - - public boolean isEmpty(){ //这里多写了一个判断队列为空的操作,方便之后使用 - return head.next == null; //直接看头结点后面还有没有东西就行了 - } - - private static class Node { - E element; - Node next; - - public Node(E element) { - this.element = element; - } - } -} -``` - -我们来尝试编写一下层序遍历: - -```java -private static void levelOrder(TreeNode root){ - LinkedQueue> queue = new LinkedQueue<>(); //创建一个队列 - queue.offer(root); //将根结点丢进队列 - while (!queue.isEmpty()) { //如果队列不为空,就一直不断地取出来 - TreeNode node = queue.poll(); //取一个出来 - System.out.print(node.element); //打印 - if(node.left != null) queue.offer(node.left); //如果左右孩子不为空,直接将左右孩子丢进队列 - if(node.right != null) queue.offer(node.right); - } -} -``` - -可以看到结果就是层序遍历的结果: - -![image-20220807215630429](https://s2.loli.net/2022/08/07/YlUfDhPoQrg9TkB.png) - -当然,使用递归也可以实现,但是需要单独存放结果然后单独输出,不是很方便,所以说这里就不演示了。 - -### 树:二叉查找树和平衡二叉树 - -**注意:**本部分只进行理论介绍,不做代码实现。 - -还记得我们开篇讲到的二分搜索算法吗?通过不断缩小查找范围,最终我们可以以很高的效率找到有序数组中的目标位置。而二叉查找树则利用了类似的思想,我们可以借助其来像二分搜索那样快速查找。 - -**二叉查找树**也叫二叉搜索树或是二叉排序树,它具有一定的规则: - -* 左子树中所有结点的值,均小于其根结点的值。 -* 右子树中所有结点的值,均大于其根结点的值。 -* 二叉搜索树的子树也是二叉搜索树。 - -一棵二叉搜索树长这样: - -![image-20220814191444130](https://s2.loli.net/2022/08/14/k9G7Ad2cqezgEtJ.png) - -这棵树的根结点为18,而其根结点左边子树的根结点为10,包括后续结点,都是满足上述要求的。二叉查找树满足左边一定比当前结点小,右边一定比当前结点大的规则,比如我们现在需要在这颗树种查找值为15的结点: - -1. 从根结点18开始,因为15小于18,所以从左边开始找。 -2. 接着来到10,发现10比15小,所以继续往右边走。 -3. 来到15,成功找到。 - -实际上,我们在对普通二叉树进行搜索时,可能需要挨个进行查看比较,而有了二叉搜索树,查找效率就大大提升了,它就像我们前面的二分搜索那样。 - -利用二叉查找树,我们在搜索某个值的时候,效率会得到巨大提升。但是虽然看起来比较完美,也是存在缺陷的,比如现在我们依次将下面的值插入到这棵二叉树中: - -``` -20 15 13 8 6 3 -``` - -在插入完成后,我们会发现这棵二叉树竟然长这样: - -![image-20220815113242191](https://s2.loli.net/2022/08/15/E1Pf2pGv4b9Lj7t.png) - -因为根据我们之前编写的插入规则,小的一律往左边放,现在正好来的就是这样一串递减的数字,最后就组成了这样的一棵只有一边的二叉树,这种情况,与其说它是一棵二叉树,不如说就是一个链表,如果这时我们想要查找某个结点,那么实际上查找的时间并没有得到任何优化,直接就退化成线性查找了。 - -所以,二叉查找树只有在理想情况下,查找效率才是最高的,而像这种极端情况,就性能而言几乎没有任何的提升。我们理想情况下,这样的效率是最高的: - -![image-20220815113705827](https://s2.loli.net/2022/08/15/k1jzXPoOMp9caHy.png) - -所以,我们在进行结点插入时,需要尽可能地避免这种一边倒的情况,这里就需要引入**平衡二叉树**的概念了。实际上我们发现,在插入时如果不去维护二叉树的平衡,某一边只会无限制地延伸下去,出现极度不平衡的情况,而我们理想中的二叉查找树左右是尽可能保持平衡的,**平衡二叉树**(AVL树)就是为了解决这样的问题而生的。 - -它的性质如下: - -* 平衡二叉树一定是一棵二叉查找树。 -* 任意结点的左右子树也是一棵平衡二叉树。 -* 从根节点开始,左右子树都高度差不能超过1,否则视为不平衡。 - -可以看到,这些性质规定了平衡二叉树需要保持高度平衡,这样我们的查找效率才不会因为数据的插入而出现降低的情况。二叉树上节点的左子树高度 减去 右子树高度, 得到的结果称为该节点的**平衡因子**(Balance Factor),比如: - -![image-20220815210652973](https://s2.loli.net/2022/08/15/vaI9qji1KYOP8kt.png) - -通过计算平衡因子,我们就可以快速得到是否出现失衡的情况。比如下面的这棵二叉树,正在执行插入操作: - -![image-20220815115219250](https://s2.loli.net/2022/08/15/DMnPqGhawy5Z92V.png) - -可以看到,当插入之后,不再满足平衡二叉树的定义时,就出现了失衡的情况,而对于这种失衡情况,为了继续保持平衡状态,我们就需要进行处理了。我们可能会遇到以下几种情况导致失衡: - -![image-20220815115836604](https://s2.loli.net/2022/08/15/KcOQVhlFxzwsIb9.png) - -根据插入结点的不同偏向情况,分为LL型、LR型、RR型、RL型。针对于上面这几种情况,我们依次来看一下如何进行调整,使得这棵二叉树能够继续保持平衡: - -动画网站:https://www.cs.usfca.edu/~galles/visualization/AVLtree.html(实在不理解可以看看动画是怎么走的) - -1. **LL型调整**(右旋) - - ![image-20220815211641144](https://s2.loli.net/2022/08/15/KqBaWLJwOj34Ec8.png) - - 首先我们来看这种情况,这是典型的LL型失衡,为了能够保证二叉树的平衡,我们需要将其进行**旋转**来维持平衡,去纠正最小不平衡子树即可。那么怎么进行旋转呢?对于LL型失衡,我们只需要进行右旋操作,首先我们先找到最小不平衡子树,注意是最小的那一个: - - ![image-20220815212552176](https://s2.loli.net/2022/08/15/q4aYvzrnjdTgAtK.png) - - 可以看到根结点的平衡因子是2,是目前最小的出现不平衡的点,所以说从根结点开始向左的三个结点需要进行右旋操作,右旋需要将这三个结点中间的结点作为新的根结点,而其他两个结点现在变成左右子树: - - ![image-20220815213222964](https://s2.loli.net/2022/08/15/fJKz3FWclm9orVT.png) - - 这样,我们就完成了右旋操作,可以看到右旋之后,所有的结点继续保持平衡,并且依然是一棵二叉查找树。 - -2. **RR型调整**(左旋) - - 前面我们介绍了LL型以及右旋解决方案,相反的,当遇到RR型时,我们只需要进行左旋操作即可: - - ![image-20220815214026710](https://s2.loli.net/2022/08/15/kIl8ZT6Psr7mNSg.png) - - 操作和上面是一样的,只不过现在反过来了而已: - - ![image-20220815214408651](https://s2.loli.net/2022/08/15/LB9DOJpyIlxQWTm.png) - - 这样,我们就完成了左旋操作,使得这棵二叉树继续保持平衡状态了。 - -3. **RL型调整**(先右旋,再左旋) - - 剩下两种类型比较麻烦,需要旋转两次才行。我们来看看RL型长啥样: - - ![image-20220815214859501](https://s2.loli.net/2022/08/15/fwcrEIgBxWLVGXs.png) - - 可以看到现在的形状是一个回旋镖形状的,先右后左的一个状态,也就是RL型,针对于这种情况,我们需要先进行右旋操作,注意这里的右旋操作针对的是后两个结点: - - ![image-20220815215929303](https://s2.loli.net/2022/08/15/ukK6C4PNBwoaJbc.png) - - 其中右旋和左旋的操作,与之前一样,该怎么分配左右子树就怎么分配,完成两次旋转后,可以看到二叉树重新变回了平衡状态。 - -4. **LR型调整**(先左旋,再右旋) - - 和上面一样,我们来看看LR型长啥样,其实就是反着的: - - ![image-20220815220609357](https://s2.loli.net/2022/08/15/6Cj8VlgGekULXvP.png) - - 形状是先向左再向右,这就是典型的LR型了,我们同样需要对其进行两次旋转: - - ![image-20220815221349044](https://s2.loli.net/2022/08/15/y6WscFPxHuzTiaI.png) - - 这里我们先进行的是左旋,然后再进行的右旋,这样二叉树就能继续保持平衡了。 - -这样,我们只需要在插入结点时注意维护整棵树的平衡因子,保证其处于稳定状态,这样就可以让这棵树一直处于高度平衡的状态,不会再退化了。 - -### 树:红黑树 - -**注意:**本部分只进行理论介绍,不做代码实现。 - -很多人都说红黑树难,其实就那几条规则,跟着我推一遍其实还是很简单的,当然前提是一定要把前面的平衡二叉树搞明白。 - -前面我们讲解了二叉平衡树,通过在插入结点时维护树的平衡,这样就不会出现极端情况使得整棵树的查找效率急剧降低了。但是这样是否开销太大了一点,因为一旦平衡因子的绝对值超过1那么就失衡,这样每插入一个结点,就有很大的概率会导致失衡,我们能否不这么严格,但同时也要在一定程度上保证平衡呢?这就要提到红黑树了。 - -在线动画网站:https://www.cs.usfca.edu/~galles/visualization/RedBlack.html - -红黑树也是二叉查找树的一种,它大概长这样,可以看到结点有红有黑: - -![image-20220815222810537](https://s2.loli.net/2022/08/15/t86B7sxvYeP9TiR.png) - -它并不像平衡二叉树那样严格要求高度差不能超过1,而是只需要满足五个规则即可,它的规则如下: - -- 规则1:每个结点可以是黑色或是红色。 -- 规则2:根结点一定是黑色。 -- 规则3:红色结点的父结点和子结点不能为红色,也就是说不能有两个连续的红色。 -- 规则4:所有的空结点都是黑色(空结点视为NIL,红黑树中是将空节点视为叶子结点) -- 规则5:每个结点到空节点(NIL)路径上出现的黑色结点的个数都相等。 - -它相比平衡二叉树,通过不严格平衡和改变颜色,就能在一定程度上减少旋转次数,这样的话对于整体性能是有一定提升的,只不过我们在插入结点时,就有点麻烦了,我们需要同时考虑变色和旋转这两个操作了,但是会比平衡二叉树更简单。 - -那么什么时候需要变色,什么时候需要旋转呢?我们通过一个简单例子来看看: - -![image-20220816104917851](https://s2.loli.net/2022/08/16/wIj5qnhxFAHcyG7.png) - -首先这棵红黑树只有一个根结点,因为根结点必须是黑色,所以说直接变成黑色。现在我们要插入一个新的结点了,所有新插入的结点,默认情况下都是红色: - -![image-20220816105119178](https://s2.loli.net/2022/08/16/yHRXgbsvOM27xLr.png) - -所以新来的结点7根据规则就直接放到11的左边就行了,然后注意7的左右两边都是NULL,那么默认都是黑色,这里就不画出来了。同样的,我们往右边也来一个: - -![image-20220816105553070](https://s2.loli.net/2022/08/16/kJiA71fQuKHnIdb.png) - -现在我们继续插入一个结点: - -![image-20220816105656320](https://s2.loli.net/2022/08/16/VEQLu5mb1tcTyzd.png) - -插入结点4之后,此时违反了红黑树的规则3,因为红色结点的父结点和子结点不能为红色,此时为了保持以红黑树的性质,我们就需要进行**颜色变换**才可以,那么怎么进行颜色变换呢?我们只需要直接将父结点和其兄弟结点同时修改为黑色(为啥兄弟结点也需要变成黑色?因为要满足性质5)然后将爷爷结点改成红色即可: - -![image-20220816113259643](https://s2.loli.net/2022/08/16/kuc1B3lqhNUwaSM.png) - -当然这里还需注意一下,因为爷爷结点正常情况会变成红色,相当于新来了个红色的,这时还得继续往上看有没有破坏红黑树的规则才可以,直到没有为止,比如这里就破坏了性质一,爷爷结点现在是根结点(不是根结点就不需要管了),必须是黑色,所以说还要给它改成黑色才算结束: - -![image-20220816113339344](https://s2.loli.net/2022/08/16/dpRX5DGsfWVwnQi.png) - -接着我们继续插入结点: - -![image-20220816113939172](https://s2.loli.net/2022/08/16/4ZAhv7R9YusI8q6.png) - -此时又来了一个插在4左边的结点,同样是连续红色,我们需要进行变色才可以讲解问题,但是我们发现,如果变色的话,那么从11开始到所有NIL结点经历的黑色结点数量就不对了: - -![image-20220816114245996](https://s2.loli.net/2022/08/16/n3M6Kfsb4jHtIci.png) - -所以说对于这种**父结点为红色,父结点的兄弟结点为黑色**(NIL视为黑色)的情况,变色无法解决问题了,那么我们只能考虑旋转了,旋转规则和我们之前讲解的平衡二叉树是一样的,这实际上是一种LL型失衡: - -![image-20220816115015892](https://s2.loli.net/2022/08/16/POTaBfosmQiceWk.png) - -同样的,如果遇到了LR型失衡,跟前面一样,先左旋在右旋,然后进行变色即可: - -![image-20220816115924938](https://s2.loli.net/2022/08/16/XqFr7hJwe38AakK.png) - -而RR型和RL型同理,这里就不进行演示了,可以看到,红黑树实际上也是通过颜色规则在进行旋转调整的,当然旋转和变色的操作顺序可以交换。所以,在插入时比较关键的判断点如下: - -* 如果整棵树为NULL,直接作为根结点,变成黑色。 -* 如果父结点是黑色,直接插入就完事。 -* 如果父结点为红色,且父结点的兄弟结点也是红色,直接变色即可(但是注意得继续往上看有没有破坏之前的结构) -* 如果父结点为红色,但父结点的兄弟结点为黑色,需要先根据情况(LL、RR、LR、RL)进行旋转,然后再变色。 - -在了解这些步骤之后,我们其实已经可以尝试去编写一棵红黑树出来了,当然代码太过复杂,这里就不演示了。 - -### 哈希表 - -在之前,我们已经学习了多种查找数据的方式,比如最简单的,如果数据量不大的情况下,我们可以直接通过顺序查找的方式在集合中搜索我们想要的元素;当数据量较大时,我们可以使用二分搜索来快速找到我们想要的数据,不过需要要求数据按照顺序排列,并且不允许中途对集合进行修改。 - -在学习完树形结构篇之后,我们可以利用二叉查找树来建立一个便于我们查找的树形结构,甚至可以将其优化为平衡二叉树或是红黑树来进一步提升稳定性。 - -这些都能够极大地帮助我们查找数据,而散列表,则是我们数据结构系列内容的最后一块重要知识。 - -散列(Hashing)通过散列函数(哈希函数)将要参与检索的数据与散列值(哈希值)关联起来,生成一种便于搜索的数据结构,我们称其为散列表(哈希表),也就是说,现在我们需要将一堆数据保存起来,这些数据会通过哈希函数进行计算,得到与其对应的哈希值,当我们下次需要查找这些数据时,只需要再次计算哈希值就能快速找到对应的元素了: - -![image-20220818214145347](https://s2.loli.net/2022/08/18/Tcj6Spy2Pt5ZIuW.png) - -散列函数也叫哈希函数,哈希函数可以对一个目标计算出其对应的哈希值,并且,只要是同一个目标,无论计算多少次,得到的哈希值都是一样的结果,不同的目标计算出的结果介乎都不同。哈希函数在现实生活中应用十分广泛,比如很多下载网站都提供下载文件的MD5码校验,可以用来判别文件是否完整,哈希函数多种多样,目前应用最为广泛的是SHA-1和MD5,比如我们在下载IDEA之后,会看到有一个验证文件SHA-256校验和的选项,我们可以点进去看看: - -![image-20220818214908458](https://s2.loli.net/2022/08/18/tD8AjiGwvJkdahE.png) - -点进去之后,得到: - -``` -e54a026da11d05d9bb0172f4ef936ba2366f985b5424e7eecf9e9341804d65bf *ideaIU-2022.2.1.dmg -``` - -这一串由数字和小写字母随意组合的一个字符串,就是安装包文件通过哈希算法计算得到的结果,那么这个东西有什么用呢?我们的网络可能有时候会出现卡顿的情况,导致我们下载的文件可能会出现不完整的情况,因为哈希函数对同一个文件计算得到的结果是一样的,我们可以在本地使用同样的哈希函数去计算下载文件的哈希值,如果与官方一致,那么就说明是同一个文件,如果不一致,那么说明文件在传输过程中出现了损坏。 - -可见,哈希函数在这些地方就显得非常实用,在我们的生活中起了很大的作用,它也可以用于布隆过滤器和负载均衡等场景,这里不多做介绍了。 - -前面我们介绍了散列函数,我们知道可以通过散列函数计算一个目标的哈希值,那么这个哈希值计算出来有什么用呢,对我们的程序设计有什么意义呢?我们可以利用哈希值的特性,设计一张全新的表结构,这种表结构是专为哈希设立的,我们称其为哈希表(散列表) - -![image-20220818220944783](https://s2.loli.net/2022/08/18/M2o1vE7hHasN8DP.png) - -我们可以将这些元素保存到哈希表中,而保存的位置则与其对应的哈希值有关,哈希值是通过哈希函数计算得到的,我们只需要将对应元素的关键字(一般是整数)提供给哈希函数就可以进行计算了,一般比较简单的哈希函数就是取模操作,哈希表长度是多少(长度最好是一个素数),模就是多少: - -![image-20220819170355221](https://s2.loli.net/2022/08/19/CAPhlJnQeLjMHfd.png) - -比如现在我们需要插入一个新的元素(关键字为17)到哈希表中: - -![image-20220819171430332](https://s2.loli.net/2022/08/19/ovieRjrzlXhKMC2.png) - -插入的位置为计算出来的哈希值,比如上面是8,那么就在下标位置8插入元素,同样的,我们继续插入27: - -![image-20220819210336314](https://s2.loli.net/2022/08/19/pisuSAIZyf5JE7B.png) - -这样,我们就可以将多种多样的数据保存到哈希表中了,注意保存的数据是无序的,因为我们也不清楚计算完哈希值最后会放到哪个位置。那么如果现在我们想要从哈希表中查找数据呢?比如我们现在需要查找哈希表中是否有14这个元素: - -![image-20220819211656628](https://s2.loli.net/2022/08/19/H1hAvQPjNui2RYt.png) - -同样的,直接去看哈希值对应位置上看看有没有这个元素,如果没有,那么就说明哈希表中没有这个元素。可以看到,哈希表在查找时只需要进行一次哈希函数计算就能直接找到对应元素的存储位置,效率极高。 - -我们来尝试编写一下: - -```java -public class HashTable { - private final int TABLE_SIZE = 10; - private final Object[] TABLE = new Object[TABLE_SIZE]; - - public void insert(E element){ - int index = hash(element); - TABLE[index] = element; - } - - public boolean contains(E element){ - int index = hash(element); - return TABLE[index] == element; - } - - private int hash(Object object){ //哈希函数,计算出存放的位置 - int hashCode = object.hashCode(); - //每一个对象都有一个独一无二的哈希值,可以通过hashCode方法得到(只有极小的概率会出现相同的情况) - return hashCode % TABLE_SIZE; - } -} -``` - -这样,我们就实现了一个简单的哈希表和哈希函数,通过哈希表,我们可以将数据的查找时间复杂度提升到常数阶。 - -前面我介绍了哈希函数,通过哈希函数计算得到一个目标的哈希值,但是在某些情况下,哈希值可能会出现相同的情况: - -![image-20220819215004653](https://s2.loli.net/2022/08/19/XqpZd1YP5ulEJRy.png) - -比如现在同时插入14和23这两个元素,他们两个计算出来的哈希值是一样的,都需要在5号下标位置插入,这时就出现了打架的情况,那么到底是把哪一个放进去呢?这种情况,我们称为**哈希碰撞**(哈希冲突) - -这种问题是很严重的,因为哈希函数的设计不同,难免会出现这种情况,这种情况是不可避免的,我们只能通过使用更加高级的哈希函数来尽可能避免这种情况,但是无法完全避免。当然,如果要完全解决这种问题,我们还需要去寻找更好的方法。这里我们只介绍一种比较重要的,会在后面集合类中用到的方案。 - -实际上常见的哈希冲突解决方案是**链地址法**,当出现哈希冲突时,我们依然将其保存在对应的位置上,我们可以将其连接为一个链表的形式: - -![image-20220820220237535](https://s2.loli.net/2022/09/30/Hd1LDvkY6ScVTN2.png) - -当表中元素变多时,差不多就变成了这样,我们一般将其横过来看: - -![image-20220820221104298](https://s2.loli.net/2022/09/30/kr4CcVEwI72AiDU.png) - -通过结合链表的形式,哈希冲突问题就可以得到解决了,但是同时也会出现一定的查找开销,因为现在有了链表,我们得挨个往后看才能找到,当链表变得很长时,查找效率也会变低,此时我们可以考虑结合其他的数据结构来提升效率。比如当链表长度达到8时,自动转换为一棵平衡二叉树或是红黑树,这样就可以在一定程度上缓解查找的压力了。 - -```java -public class HashTable { - private final int TABLE_SIZE = 10; - private final Node[] TABLE = new Node[TABLE_SIZE]; - - public HashTable(){ - for (int i = 0; i < TABLE_SIZE; i++) - TABLE[i] = new Node<>(null); - } - - public void insert(E element){ - int index = hash(element); - Node prev = TABLE[index]; - while (prev.next != null) - prev = prev.next; - prev.next = new Node<>(element); - } - - public boolean contains(E element){ - int index = hash(element); - Node node = TABLE[index].next; - while (node != null) { - if(node.element == element) - return true; - node = node.next; - } - return false; - } - - private int hash(Object object){ - int hashCode = object.hashCode(); - return hashCode % TABLE_SIZE; - } - - private static class Node { - private final E element; - private Node next; - - private Node(E element){ - this.element = element; - } - } -} -``` - -实际上这种方案代码写起来也会更简单,使用也更方便一些。 - -至此,数据结构相关内容,我们就讲解到这里,学习这些数据结构,实际上也是为了方便各位小伙伴对于后续结合类的学习,因为集合类的底层实现就是这些数据结构。 - -*** - -## 实战练习 - -合理利用集合类,我们可以巧妙地解决各种各样的难题。 - -### 反转链表 - -本题来自LeetCode:[206. 反转链表](https://leetcode.cn/problems/reverse-linked-list/) - -给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。 - -示例 1: - -![img](https://assets.leetcode.com/uploads/2021/02/19/rev1ex1.jpg) - -> 输入:head = [1,2,3,4,5] -> 输出:[5,4,3,2,1] - -示例 2: - -![img](https://assets.leetcode.com/uploads/2021/02/19/rev1ex2.jpg) - -> 输入:head = [1,2] -> 输出:[2,1] - -这道题依然是考察各位小伙伴对于链表相关操作的掌握程度,我们如何才能将一个链表的顺序进行反转,关键就在于如何修改每个节点的指针指向。 - -### 括号匹配问题 - -本题来自LeetCode:[20. 有效的括号](https://leetcode.cn/problems/valid-parentheses/) - -给定一个只包括 '(',')','{','}','[',']' 的字符串 s ,判断字符串是否有效。 - -有效字符串需满足: - -1. 左括号必须用相同类型的右括号闭合。 -2. 左括号必须以正确的顺序闭合。 - -示例 1: - -> 输入:s = "()" -> 输出:true - -示例 2: - -> 输入:s = "()[]{}" -> 输出:true - -示例 3: - -> 输入:s = "(]" -> 输出:false - -**示例 4:** - -> 输入:s = "([)]" -> 输出:false - -**示例 5:** - -> 输入:s = "{[]}" -> 输出:true - -题干很明确,就是需要我们去对这些括号完成匹配,如果给定字符串中的括号无法完成一一匹配的话,那么就表示匹配失败。实际上这种问题我们就可以利用前面学习的栈这种数据结构来解决,我们可以将所有括号的左半部分放入栈中,当遇到右半部分时,进行匹配,如果匹配失败,那么就失败,如果匹配成功,那么就消耗一个左半部分,直到括号消耗完毕。 - -### 实现计算器 - -输入一个计算公式(含加减乘除运算符,没有负数但是有小数)得到结果,比如输入:1+4*3/1.321,得到结果为:2.2 - -现在请你设计一个Java程序,实现计算器。 \ No newline at end of file diff --git a/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(六)重制版.md b/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(六)重制版.md deleted file mode 100644 index 4205f14..0000000 --- a/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(六)重制版.md +++ /dev/null @@ -1,2534 +0,0 @@ -![image-20221004131436371](https://s2.loli.net/2022/10/04/SkAn9RQpqC4tVW5.png) - -# 集合类与IO - -前面我们已经把基础介绍完了,从这节课开始,我们就正式进入到集合类的讲解中。 - -## 集合类 - -集合类是Java中非常重要的存在,使用频率极高。集合其实与我们数学中的集合是差不多的概念,集合表示一组对象,每一个对象我们都可以称其为元素。不同的集合有着不同的性质,比如一些集合允许重复的元素,而另一些则不允许,一些集合是有序的,而其他则是无序的。 - -![image-20220930233059528](https://s2.loli.net/2022/09/30/ZWxPduaYGgRzmNO.png) - -集合类其实就是为了更好地组织、管理和操作我们的数据而存在的,包括列表、集合、队列、映射等数据结构。从这一块开始,我们会从源码角度给大家讲解(先从接口定义对于集合需要实现哪些功能开始说起,包括这些集合类的底层机制是如何运作的)不仅仅是教会大家如何去使用。 - -集合跟数组一样,可以表示同样的一组元素,但是他们的相同和不同之处在于: - -1. 它们都是容器,都能够容纳一组元素。 - -不同之处: - -1. 数组的大小是固定的,集合的大小是可变的。 -2. 数组可以存放基本数据类型,但集合只能存放对象。 -3. 数组存放的类型只能是一种,但集合可以有不同种类的元素。 - -### 集合根接口 - -Java中已经帮我们将常用的集合类型都实现好了,我们只需要直接拿来用就行了,比如我们之前学习的顺序表: - -```java -import java.util.ArrayList; //集合类基本都是在java.util包下定义的 - -public class Main { - public static void main(String[] args) { - ArrayList list = new ArrayList<>(); - list.add("树脂666"); - } -} -``` - -当然,我们会在这一部分中认识大部分Java为我们提供的集合类。所有的集合类最终都是实现自集合根接口的,比如我们下面就会讲到的ArrayList类,它的祖先就是Collection接口: - -![image-20220930232759715](https://s2.loli.net/2022/09/30/U9DdJinhCp6BITe.png) - -这个接口定义了集合类的一些基本操作,我们来看看有哪些方法: - -```java -public interface Collection extends Iterable { - //-------这些是查询相关的操作---------- - - //获取当前集合中的元素数量 - int size(); - - //查看当前集合是否为空 - boolean isEmpty(); - - //查询当前集合中是否包含某个元素 - boolean contains(Object o); - - //返回当前集合的迭代器,我们会在后面介绍 - Iterator iterator(); - - //将集合转换为数组的形式 - Object[] toArray(); - - //支持泛型的数组转换,同上 - T[] toArray(T[] a); - - //-------这些是修改相关的操作---------- - - //向集合中添加元素,不同的集合类具体实现可能会对插入的元素有要求, - //这个操作并不是一定会添加成功,所以添加成功返回true,否则返回false - boolean add(E e); - - //从集合中移除某个元素,同样的,移除成功返回true,否则false - boolean remove(Object o); - - - //-------这些是批量执行的操作---------- - - //查询当前集合是否包含给定集合中所有的元素 - //从数学角度来说,就是看给定集合是不是当前集合的子集 - boolean containsAll(Collection c); - - //添加给定集合中所有的元素 - //从数学角度来说,就是将当前集合变成当前集合与给定集合的并集 - //添加成功返回true,否则返回false - boolean addAll(Collection c); - - //移除给定集合中出现的所有元素,如果某个元素在当前集合中不存在,那么忽略这个元素 - //从数学角度来说,就是求当前集合与给定集合的差集 - //移除成功返回true,否则false - boolean removeAll(Collection c); - - //Java8新增方法,根据给定的Predicate条件进行元素移除操作 - default boolean removeIf(Predicate filter) { - Objects.requireNonNull(filter); - boolean removed = false; - final Iterator each = iterator(); //这里用到了迭代器,我们会在后面进行介绍 - while (each.hasNext()) { - if (filter.test(each.next())) { - each.remove(); - removed = true; - } - } - return removed; - } - - //只保留当前集合中在给定集合中出现的元素,其他元素一律移除 - //从数学角度来说,就是求当前集合与给定集合的交集 - //移除成功返回true,否则false - boolean retainAll(Collection c); - - //清空整个集合,删除所有元素 - void clear(); - - - //-------这些是比较以及哈希计算相关的操作---------- - - //判断两个集合是否相等 - boolean equals(Object o); - - //计算当前整个集合对象的哈希值 - int hashCode(); - - //与迭代器作用相同,但是是并行执行的,我们会在下一章多线程部分中进行介绍 - @Override - default Spliterator spliterator() { - return Spliterators.spliterator(this, 0); - } - - //生成当前集合的流,我们会在后面进行讲解 - default Stream stream() { - return StreamSupport.stream(spliterator(), false); - } - - //生成当前集合的并行流,我们会在下一章多线程部分中进行介绍 - default Stream parallelStream() { - return StreamSupport.stream(spliterator(), true); - } -} -``` - -可以看到,在这个接口中对于集合相关的操作,还是比较齐全的,那么我们接着就来看看它的实现类。 - -### List列表 - -首先我们需要介绍的是List列表(线性表),线性表支持随机访问,相比之前的Collection接口定义,功能还会更多一些。首先介绍ArrayList,我们已经知道,它的底层是用数组实现的,内部维护的是一个可动态进行扩容的数组,也就是我们之前所说的顺序表,跟我们之前自己写的ArrayList相比,它更加的规范,并且功能更加强大,同时实现自List接口。 - -![image-20220930232759715](https://s2.loli.net/2022/09/30/U9DdJinhCp6BITe.png) - -List是集合类型的一个分支,它的主要特性有: - -* 是一个有序的集合,插入元素默认是插入到尾部,按顺序从前往后存放,每个元素都有一个自己的下标位置 -* 列表中允许存在重复元素 - -在List接口中,定义了列表类型需要支持的全部操作,List直接继承自前面介绍的Collection接口,其中很多地方重新定义了一次Collection接口中定义的方法,这样做是为了更加明确方法的具体功能,当然,为了直观,我们这里就省略掉: - -```java -//List是一个有序的集合类,每个元素都有一个自己的下标位置 -//List中可插入重复元素 -//针对于这些特性,扩展了Collection接口中一些额外的操作 -public interface List extends Collection { - ... - - //将给定集合中所有元素插入到当前结合的给定位置上(后面的元素就被挤到后面去了,跟我们之前顺序表的插入是一样的) - boolean addAll(int index, Collection c); - - ... - - //Java 8新增方法,可以对列表中每个元素都进行处理,并将元素替换为处理之后的结果 - default void replaceAll(UnaryOperator operator) { - Objects.requireNonNull(operator); - final ListIterator li = this.listIterator(); //这里同样用到了迭代器 - while (li.hasNext()) { - li.set(operator.apply(li.next())); - } - } - - //对当前集合按照给定的规则进行排序操作,这里同样只需要一个Comparator就行了 - @SuppressWarnings({"unchecked", "rawtypes"}) - default void sort(Comparator c) { - Object[] a = this.toArray(); - Arrays.sort(a, (Comparator) c); - ListIterator i = this.listIterator(); - for (Object e : a) { - i.next(); - i.set((E) e); - } - } - - ... - - //-------- 这些是List中独特的位置直接访问操作 -------- - - //获取对应下标位置上的元素 - E get(int index); - - //直接将对应位置上的元素替换为给定元素 - E set(int index, E element); - - //在指定位置上插入元素,就跟我们之前的顺序表插入是一样的 - void add(int index, E element); - - //移除指定位置上的元素 - E remove(int index); - - - //------- 这些是List中独特的搜索操作 ------- - - //查询某个元素在当前列表中的第一次出现的下标位置 - int indexOf(Object o); - - //查询某个元素在当前列表中的最后一次出现的下标位置 - int lastIndexOf(Object o); - - - //------- 这些是List的专用迭代器 ------- - - //迭代器我们会在下一个部分讲解 - ListIterator listIterator(); - - //迭代器我们会在下一个部分讲解 - ListIterator listIterator(int index); - - //------- 这些是List的特殊转换 ------- - - //返回当前集合在指定范围内的子集 - List subList(int fromIndex, int toIndex); - - ... -} -``` - -可以看到,在List接口中,扩展了大量列表支持的操作,其中最突出的就是直接根据下标位置进行的增删改查操作。而在ArrayList中,底层就是采用数组实现的,跟我们之前的顺序表思路差不多: - -```java -public class ArrayList extends AbstractList - implements List, RandomAccess, Cloneable, java.io.Serializable -{ - - //默认的数组容量 - private static final int DEFAULT_CAPACITY = 10; - - ... - - //存放数据的底层数组,这里的transient关键字我们会在后面I/O中介绍用途 - transient Object[] elementData; - - //记录当前数组元素数的 - private int size; - - //这是ArrayList的其中一个构造方法 - public ArrayList(int initialCapacity) { - if (initialCapacity > 0) { - this.elementData = new Object[initialCapacity]; //根据初始化大小,创建当前列表 - } else if (initialCapacity == 0) { - this.elementData = EMPTY_ELEMENTDATA; - } else { - throw new IllegalArgumentException("Illegal Capacity: "+ - initialCapacity); - } - } - - ... - - public boolean add(E e) { - ensureCapacityInternal(size + 1); // 这里会判断容量是否充足,不充足需要扩容 - elementData[size++] = e; - return true; - } - - ... - - //默认的列表最大长度为Integer.MAX_VALUE - 8 - //JVM都C++实现中,在数组的对象头中有一个_length字段,用于记录数组的长 - //度,所以这个8就是存了数组_length字段(这个只做了解就行) - private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; - - private void grow(int minCapacity) { - int oldCapacity = elementData.length; - int newCapacity = oldCapacity + (oldCapacity >> 1); //扩容规则跟我们之前的是一样的,也是1.5倍 - if (newCapacity - minCapacity < 0) //要是扩容之后的大小还没最小的大小大,那么直接扩容到最小的大小 - newCapacity = minCapacity; - if (newCapacity - MAX_ARRAY_SIZE > 0) //要是扩容之后比最大的大小还大,需要进行大小限制 - newCapacity = hugeCapacity(minCapacity); //调整为限制的大小 - elementData = Arrays.copyOf(elementData, newCapacity); //使用copyOf快速将内容拷贝到扩容后的新数组中并设定为新的elementData底层数组 - } -} -``` - -一般的,如果我们要使用一个集合类,我们会使用接口的引用: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(); //使用接口的引用来操作具体的集合类实现,是为了方便日后如果我们想要更换不同的集合类实现,而且接口中本身就已经定义了主要的方法,所以说没必要直接用实现类 - list.add("科技与狠活"); //使用add添加元素 - list.add("上头啊"); - System.out.println(list); //打印集合类,可以得到一个非常规范的结果 -} -``` - -可以看到,打印集合类的效果,跟我们使用Arrays工具类是一样的: - -![image-20221001002151164](https://s2.loli.net/2022/10/01/v3uzfnhamXV5St8.png) - -集合的各种功能我们都可以来测试一下,特别注意一下,我们在使用Integer时,要注意传参问题: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(); - list.add(10); //添加Integer的值10 - list.remove((Integer) 10); //注意,不能直接用10,默认情况下会认为传入的是int类型值,删除的是下标为10的元素,我们这里要删除的是刚刚传入的值为10的Integer对象 - System.out.println(list); //可以看到,此时元素成功被移除 -} -``` - -那要是这样写呢? - -```java -public static void main(String[] args) { - List list = new ArrayList<>(); - list.add(new Integer(10)); //添加的是一个对象 - list.remove(new Integer(10)); //删除的是另一个对象 - System.out.println(list); -} -``` - -可以看到,结果依然是删除成功,这是因为集合类在删除元素时,只会调用`equals`方法进行判断是否为指定元素,而不是进行等号判断,所以说一定要注意,如果两个对象使用`equals`方法相等,那么集合中就是相同的两个对象: - -```java -//ArrayList源码部分 -public boolean remove(Object o) { - if (o == null) { - ... - } else { - for (int index = 0; index < size; index++) - if (o.equals(elementData[index])) { //这里只是对两个对象进行equals判断 - fastRemove(index); - return true; //只要判断成功,直接认为就是要删除的对象,删除就完事 - } - } - return false; -} -``` - -列表中允许存在相同元素,所以说我们可以添加两个一模一样的: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(); - String str = "哟唉嘛干你"; - list.add(str); - list.add(str); - System.out.println(list); -} -``` - -![image-20221001231509926](https://s2.loli.net/2022/10/01/paeKLsGntNVfHPT.png) - -那要是此时我们删除对象呢,是一起删除还是只删除一个呢? - -```java -public static void main(String[] args) { - List list = new ArrayList<>(); - String str = "哟唉嘛干你"; - list.add(str); - list.add(str); - list.remove(str); - System.out.println(list); -} -``` - -![image-20221001231619391](https://s2.loli.net/2022/10/01/5HdFh74wlqbMoj6.png) - -可以看到,这种情况下,只会删除排在前面的第一个元素。 - -集合类是支持嵌套使用的,一个集合中可以存放多个集合,套娃嘛,谁不会: - -```java -public static void main(String[] args) { - List> list = new LinkedList<>(); - list.add(new LinkedList<>()); //集合中的每一个元素就是一个集合,这个套娃是可以一直套下去的 - System.out.println(list.get(0).isEmpty()); -} -``` - -在Arrays工具类中,我们可以快速生成一个只读的List: - -```java -public static void main(String[] args) { - List list = Arrays.asList("A", "B", "C"); //非常方便 - System.out.println(list); -} -``` - -注意,这个生成的List是只读的,不能进行修改操作,只能使用获取内容相关的方法,否则抛出 UnsupportedOperationException 异常。要生成正常使用的,我们可以将这个只读的列表作为参数传入: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(Arrays.asList("A", "B", "C")); - System.out.println(list); -} -``` - -当然,也可以利用静态代码块: - -```java -public static void main(String[] args) { - List list = new ArrayList() {{ //使用匿名内部类(匿名内部类在Java8无法使用钻石运算符,但是之后的版本可以) - add("A"); - add("B"); - add("C"); - }}; - System.out.println(list); -} -``` - -这里我们接着介绍另一个列表实现类,LinkedList同样是List的实现类,只不过它是采用的链式实现,也就是我们之前讲解的链表,只不过它是一个双向链表,也就是同时保存两个方向: - -```java -public class LinkedList - extends AbstractSequentialList - implements List, Deque, Cloneable, java.io.Serializable -{ - transient int size = 0; - - //引用首结点 - transient Node first; - - //引用尾结点 - transient Node last; - - //构造方法,很简单,直接创建就行了 - public LinkedList() { - } - - ... - - private static class Node { //内部使用的结点类 - E item; - Node next; //不仅保存指向下一个结点的引用,还保存指向上一个结点的引用 - Node prev; - - Node(Node prev, E element, Node next) { - this.item = element; - this.next = next; - this.prev = prev; - } - } - - ... -} -``` - -LinkedList的使用和ArrayList的使用几乎相同,各项操作的结果也是一样的,在什么使用使用ArrayList和LinkedList,我们需要结合具体的场景来决定,尽可能的扬长避短。 - -只不过LinkedList不仅可以当做List来使用,也可以当做双端队列使用,我们会在后面进行详细介绍。 - -### 迭代器 - -我们接着来介绍迭代器,实际上我们的集合类都是支持使用`foreach`语法的: - -```java -public static void main(String[] args) { - List list = Arrays.asList("A", "B", "C"); - for (String s : list) { //集合类同样支持这种语法 - System.out.println(s); - } -} -``` - -但是由于仅仅是语法糖,实际上编译之后: - -```java -public static void main(String[] args) { - List list = Arrays.asList("A", "B", "C"); - Iterator var2 = list.iterator(); //这里使用的是List的迭代器在进行遍历操作 - - while(var2.hasNext()) { - String s = (String)var2.next(); - System.out.println(s); - } - -} -``` - -那么这个迭代器是一个什么东西呢?我们来研究一下: - -```java -public static void main(String[] args) { - List list = Arrays.asList("A", "B", "C"); - //通过调用iterator方法快速获取当前集合的迭代器 - //Iterator迭代器本身也是一个接口,由具体的集合实现类来根据情况实现 - Iterator iterator = list.iterator(); -} -``` - -通过使用迭代器,我们就可以实现对集合中的元素的进行遍历,就像我们遍历数组那样,它的运作机制大概是: - -![image-20221002150914323](https://s2.loli.net/2022/10/02/8KS5jbTv7LoAVOs.png) - -一个新的迭代器就像上面这样,默认有一个指向集合中第一个元素的指针: - -![image-20221002151110991](https://s2.loli.net/2022/10/02/HxjfipVB9TlEbz5.png) - -每一次`next`操作,都会将指针后移一位,直到完成每一个元素的遍历,此时再调用`next`将不能再得到下一个元素。至于为什么要这样设计,是因为集合类的实现方案有很多,可能是链式存储,也有可能是数组存储,不同的实现有着不同的遍历方式,而迭代器则可以将多种多样不同的集合类遍历方式进行统一,只需要各个集合类根据自己的情况进行对应实现就行了。 - -我们来看看这个接口的源码定义了哪些操作: - -```java -public interface Iterator { - //看看是否还有下一个元素 - boolean hasNext(); - - //遍历当前元素,并将下一个元素作为待遍历元素 - E next(); - - //移除上一个被遍历的元素(某些集合不支持这种操作) - default void remove() { - throw new UnsupportedOperationException("remove"); - } - - //对剩下的元素进行自定义遍历操作 - default void forEachRemaining(Consumer action) { - Objects.requireNonNull(action); - while (hasNext()) - action.accept(next()); - } -} -``` - -在ArrayList和LinkedList中,迭代器的实现也不同,比如ArrayList就是直接按下标访问: - -```java -public E next() { - ... - cursor = i + 1; //移动指针 - return (E) elementData[lastRet = i]; //直接返回指针所指元素 -} -``` - -LinkedList就是不断向后寻找结点: - -```java -public E next() { - ... - next = next.next; //向后继续寻找结点 - nextIndex++; - return lastReturned.item; //返回结点内部存放的元素 -} -``` - -虽然这两种列表的实现不同,遍历方式也不同,但是都是按照迭代器的标准进行了实现,所以说,我们想要遍历一个集合中所有的元素,那么就可以直接使用迭代器来完成,而不需要关心集合类是如何实现,我们该怎么去遍历: - -```java -public static void main(String[] args) { - List list = Arrays.asList("A", "B", "C"); - Iterator iterator = list.iterator(); - while (iterator.hasNext()) { //每次循环一定要判断是否还有元素剩余 - System.out.println(iterator.next()); //如果有就可以继续获取到下一个元素 - } -} -``` - -注意,迭代器的使用是一次性的,用了之后就不能用了,如果需要再次进行遍历操作,那么需要重新生成一个迭代器对象。为了简便,我们可以直接使用`foreach`语法来快速遍历集合类,效果是完全一样的: - -```java -public static void main(String[] args) { - List list = Arrays.asList("A", "B", "C"); - for (String s : list) { - System.out.println(s); - } -} -``` - -在Java8提供了一个支持Lambda表达式的forEach方法,这个方法接受一个Consumer,也就是对遍历的每一个元素进行的操作: - -```java -public static void main(String[] args) { - List list = Arrays.asList("A", "B", "C"); - list.forEach(System.out::println); -} -``` - -这个效果跟上面的写法是完全一样的,因为forEach方法内部本质上也是迭代器在处理,这个方法是在Iterable接口中定义的: - -```java -default void forEach(Consumer action) { - Objects.requireNonNull(action); - for (T t : this) { //foreach语法遍历每一个元素 - action.accept(t); //调用Consumer的accept来对每一个元素进行消费 - } -} -``` - -那么我们来看一下,Iterable这个接口又是是什么东西? - -![image-20221002152713622](https://s2.loli.net/2022/10/02/4ShtiO6kdIcwZ85.png) - -我们来看看定义了哪些内容: - -```java -//注意这个接口是集合接口的父接口,不要跟之前的迭代器接口搞混了 -public interface Iterable { - //生成当前集合的迭代器,在Collection接口中重复定义了一次 - Iterator iterator(); - - //Java8新增方法,因为是在顶层接口中定义的,因此所有的集合类都有这个方法 - default void forEach(Consumer action) { - Objects.requireNonNull(action); - for (T t : this) { - action.accept(t); - } - } - - //这个方法会在多线程部分中进行介绍,暂时不做讲解 - default Spliterator spliterator() { - return Spliterators.spliteratorUnknownSize(iterator(), 0); - } -} -``` - -得益于Iterable提供的迭代器生成方法,实际上只要是实现了迭代器接口的类(我们自己写的都行),都可以使用`foreach`语法: - -```java -public class Test implements Iterable{ //这里我们随便写一个类,让其实现Iterable接口 - @Override - public Iterator iterator() { - return new Iterator() { //生成一个匿名的Iterator对象 - @Override - public boolean hasNext() { //这里随便写的,直接返回true,这将会导致无限循环 - return true; - } - - @Override - public String next() { //每次就直接返回一个字符串吧 - return "测试"; - } - }; - } -} -``` - -可以看到,直接就支持这种语法了,虽然我们这个是自己写的,并不是集合类: - -```java -public static void main(String[] args) { - Test test = new Test(); - for (String s : test) { - System.out.println(s); - } -} -``` - -![image-20221002154018319](https://s2.loli.net/2022/10/02/KejcFB8TChE5z4o.png) - -是不是感觉集合类的设计非常巧妙? - -我们这里再来介绍一下ListIterator,这个迭代器是针对于List的强化版本,增加了更多方便的操作,因为List是有序集合,所以它支持两种方向的遍历操作,不仅能从前向后,也可以从后向前: - -```java -public interface ListIterator extends Iterator { - //原本就有的 - boolean hasNext(); - - //原本就有的 - E next(); - - //查看前面是否有已经遍历的元素 - boolean hasPrevious(); - - //跟next相反,这里是倒着往回遍历 - E previous(); - - //返回下一个待遍历元素的下标 - int nextIndex(); - - //返回上一个已遍历元素的下标 - int previousIndex(); - - //原本就有的 - void remove(); - - //将上一个已遍历元素修改为新的元素 - void set(E e); - - //在遍历过程中,插入新的元素到当前待遍历元素之前 - void add(E e); -} -``` - -我们来测试一下吧: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(Arrays.asList("A", "B", "C")); - ListIterator iterator = list.listIterator(); - iterator.next(); //此时得到A - iterator.set("X"); //将A原本位置的上的元素设定为成新的 - System.out.println(list); -} -``` - -![image-20221002154844743](https://s2.loli.net/2022/10/02/C3xNDTEWGaPLfO6.png) - -这种迭代器因为能够双向遍历,所以说可以反复使用。 - -### Queue和Deque - -通过前面的学习,我们已经了解了List的使用,其中LinkedList除了可以直接当做列表使用之外,还可以当做其他的数据结构使用,可以看到它不仅仅实现了List接口: - -```java -public class LinkedList - extends AbstractSequentialList - implements List, Deque, Cloneable, java.io.Serializable -{ -``` - -这个Deque接口是干嘛的呢?我们先来看看它的继承结构: - -![image-20221002162108279](https://s2.loli.net/2022/10/02/sCMgv9rl5b743BE.png) - -我们先来看看队列接口,它扩展了大量队列相关操作: - -```java -public interface Queue extends Collection { - //队列的添加操作,是在队尾进行插入(只不过List也是一样的,默认都是尾插) - //如果插入失败,会直接抛出异常 - boolean add(E e); - - //同样是添加操作,但是插入失败不会抛出异常 - boolean offer(E e); - - //移除队首元素,但是如果队列已经为空,那么会抛出异常 - E remove(); - - //同样是移除队首元素,但是如果队列为空,会返回null - E poll(); - - //仅获取队首元素,不进行出队操作,但是如果队列已经为空,那么会抛出异常 - E element(); - - //同样是仅获取队首元素,但是如果队列为空,会返回null - E peek(); -} -``` - -我们可以直接将一个LinkedList当做一个队列来使用: - -```java -public static void main(String[] args) { - Queue queue = new LinkedList<>(); //当做队列使用,还是很方便的 - queue.offer("AAA"); - queue.offer("BBB"); - System.out.println(queue.poll()); - System.out.println(queue.poll()); -} -``` - -![image-20221002163512442](https://s2.loli.net/2022/10/02/veHxlUkKyVYErgm.png) - -我们接着来看双端队列,实际上双端队列就是队列的升级版,我们一个普通的队列就是: - -![image-20220725103600318](https://s2.loli.net/2022/07/25/xBuZckTNtR54AEq.png) - -普通队列中从队尾入队,队首出队,而双端队列允许在队列的两端进行入队和出队操作: - -![image-20221002164302507](https://s2.loli.net/2022/10/02/gn8i3teclAKbhQS.png) - -![image-20221002164431746](https://s2.loli.net/2022/10/02/in8IX3QkwtsLgWN.png) - -利用这种特性,双端队列既可以当做普通队列使用,也可以当做栈来使用,我们来看看Java中是如何定义的Deque双端队列接口的: - -```java -//在双端队列中,所有的操作都有分别对应队首和队尾的 -public interface Deque extends Queue { - //在队首进行插入操作 - void addFirst(E e); - - //在队尾进行插入操作 - void addLast(E e); - - //不用多说了吧? - boolean offerFirst(E e); - boolean offerLast(E e); - - //在队首进行移除操作 - E removeFirst(); - - //在队尾进行移除操作 - E removeLast(); - - //不用多说了吧? - E pollFirst(); - E pollLast(); - - //获取队首元素 - E getFirst(); - - //获取队尾元素 - E getLast(); - - //不用多说了吧? - E peekFirst(); - E peekLast(); - - //从队列中删除第一个出现的指定元素 - boolean removeFirstOccurrence(Object o); - - //从队列中删除最后一个出现的指定元素 - boolean removeLastOccurrence(Object o); - - // *** 队列中继承下来的方法操作是一样的,这里就不列出了 *** - - ... - - // *** 栈相关操作已经帮助我们定义好了 *** - - //将元素推向栈顶 - void push(E e); - - //将元素从栈顶出栈 - E pop(); - - - // *** 集合类中继承的方法这里也不多种介绍了 *** - - ... - - //生成反向迭代器,这个迭代器也是单向的,但是是next方法是从后往前进行遍历的 - Iterator descendingIterator(); - -} -``` - -我们可以来测试一下,比如我们可以直接当做栈来进行使用: - -```java -public static void main(String[] args) { - Deque deque = new LinkedList<>(); - deque.push("AAA"); - deque.push("BBB"); - System.out.println(deque.pop()); - System.out.println(deque.pop()); -} -``` - -![image-20221002165618791](https://s2.loli.net/2022/10/02/92woGL5MiBsTcKe.png) - -可以看到,得到的顺序和插入顺序是完全相反的,其实只要各位理解了前面讲解的数据结构,就很简单了。我们来测试一下反向迭代器和正向迭代器: - -```java -public static void main(String[] args) { - Deque deque = new LinkedList<>(); - deque.addLast("AAA"); - deque.addLast("BBB"); - - Iterator descendingIterator = deque.descendingIterator(); - System.out.println(descendingIterator.next()); - - Iterator iterator = deque.iterator(); - System.out.println(iterator.next()); -} -``` - -可以看到,正向迭代器和反向迭代器的方向是完全相反的。 - -当然,除了LinkedList实现了队列接口之外,还有其他的实现类,但是并不是很常用,这里做了解就行了: - -```java -public static void main(String[] args) { - Deque deque = new ArrayDeque<>(); //数组实现的栈和队列 - Queue queue = new PriorityQueue<>(); //优先级队列 -} -``` - -这里需要介绍一下优先级队列,优先级队列可以根据每一个元素的优先级,对出队顺序进行调整,默认情况按照自然顺序: - -```java -public static void main(String[] args) { - Queue queue = new PriorityQueue<>(); - queue.offer(10); - queue.offer(4); - queue.offer(5); - System.out.println(queue.poll()); - System.out.println(queue.poll()); - System.out.println(queue.poll()); -} -``` - -![image-20221003210253093](https://s2.loli.net/2022/10/03/bmEP9fgCS1Ksaqw.png) - -可以看到,我们的插入顺序虽然是10/4/5,但是出队顺序是按照优先级来的,类似于VIP用户可以优先结束排队。我们也可以自定义比较规则,同样需要给一个Comparator的实现: - -```java -public static void main(String[] args) { - Queue queue = new PriorityQueue<>((a, b) -> b - a); //按照从大到小顺序出队 - queue.offer(10); - queue.offer(4); - queue.offer(5); - System.out.println(queue.poll()); - System.out.println(queue.poll()); - System.out.println(queue.poll()); -} -``` - -![image-20221003210436684](https://s2.loli.net/2022/10/03/G5SZgKxvUJyPABD.png) - -只不过需要注意的是,优先级队列并不是队列中所有的元素都是按照优先级排放的,优先级队列**只能保证出队顺序是按照优先级**进行的,我们可以打印一下: - -![image-20221003210545678](https://s2.loli.net/2022/10/03/9dSheG4xqFoXB5i.png) - -想要了解优先级队列的具体是原理,可以在《数据结构与算法》篇视频教程中学习大顶堆和小顶堆。 - -### Set集合 - -前面我们已经介绍了列表,我们接着来看Set集合,这种集合类型比较特殊,我们先来看看Set的定义: - -```java -public interface Set extends Collection { - // Set集合中基本都是从Collection直接继承过来的方法,只不过对这些方法有更加特殊的定义 - int size(); - boolean isEmpty(); - boolean contains(Object o); - Iterator iterator(); - Object[] toArray(); - T[] toArray(T[] a); - - //添加元素只有在当前Set集合中不存在此元素时才会成功,如果插入重复元素,那么会失败 - boolean add(E e); - - //这个同样是删除指定元素 - boolean remove(Object o); - - boolean containsAll(Collection c); - - //同样是只能插入那些不重复的元素 - boolean addAll(Collection c); - - boolean retainAll(Collection c); - boolean removeAll(Collection c); - void clear(); - boolean equals(Object o); - int hashCode(); - - //这个方法我们同样会放到多线程中进行介绍 - @Override - default Spliterator spliterator() { - return Spliterators.spliterator(this, Spliterator.DISTINCT); - } -} -``` - -我们发现接口中定义的方法都是Collection中直接继承的,因此,Set支持的功能其实也就和Collection中定义的差不多,只不过: - -- 不允许出现重复元素 -- 不支持随机访问(不允许通过下标访问) - -首先认识一下HashSet,它的底层就是采用哈希表实现的(我们在这里先不去探讨实现原理,因为底层实质上是借用的一个HashMap在实现,这个需要我们学习了Map之后再来讨论)我们可以非常高效的从HashSet中存取元素,我们先来测试一下它的特性: - -```java -public static void main(String[] args) { - Set set = new HashSet<>(); - System.out.println(set.add("AAA")); //这里我们连续插入两个同样的字符串 - System.out.println(set.add("AAA")); - System.out.println(set); //可以看到,最后实际上只有一个成功插入了 -} -``` - -![image-20221003211330129](https://s2.loli.net/2022/10/03/y5AoUG1iuWzhOSj.png) - -在Set接口中并没有定义支持指定下标位置访问的添加和删除操作,我们只能简单的删除Set中的某个对象: - -```java -public static void main(String[] args) { - Set set = new HashSet<>(); - System.out.println(set.add("AAA")); - System.out.println(set.remove("AAA")); - System.out.println(set); -} -``` - -由于底层采用哈希表实现,所以说无法维持插入元素的顺序: - -```java -public static void main(String[] args) { - Set set = new HashSet<>(); - set.addAll(Arrays.asList("A", "0", "-", "+")); - System.out.println(set); -} -``` - -![image-20221003211635759](https://s2.loli.net/2022/10/03/OekDqMlpVbxImsK.png) - -那要是我们就是想要使用维持顺序的Set集合呢?我们可以使用LinkedHashSet,LinkedHashSet底层维护的不再是一个HashMap,而是LinkedHashMap,它能够在插入数据时利用链表自动维护顺序,因此这样就能够保证我们插入顺序和最后的迭代顺序一致了。 - -```java -public static void main(String[] args) { - Set set = new LinkedHashSet<>(); - set.addAll(Arrays.asList("A", "0", "-", "+")); - System.out.println(set); -} -``` - -![image-20221003212147700](https://s2.loli.net/2022/10/03/TpczL2Zi1OkaHWI.png) - -还有一种Set叫做TreeSet,它会在元素插入时进行排序: - -```java -public static void main(String[] args) { - TreeSet set = new TreeSet<>(); - set.add(1); - set.add(3); - set.add(2); - System.out.println(set); -} -``` - -![image-20221003212233263](https://s2.loli.net/2022/10/03/3VwDQzRxUTGrOZb.png) - -可以看到最后得到的结果并不是我们插入顺序,而是按照数字的大小进行排列。当然,我们也可以自定义排序规则: - -```java -public static void main(String[] args) { - TreeSet set = new TreeSet<>((a, b) -> b - a); //同样是一个Comparator - set.add(1); - set.add(3); - set.add(2); - System.out.println(set); -} -``` - -目前,Set集合只是粗略的进行了讲解,但是学习Map之后,我们还会回来看我们Set的底层实现,所以说最重要的还是Map。本节只需要记住Set的性质、使用即可。 - -### Map映射 - -什么是映射?我们在高中阶段其实已经学习过映射(Mapping)了,映射指两个元素的之间相互“对应”的关系,也就是说,我们的元素之间是两两对应的,是以键值对的形式存在。 - -![39e19f3e-04e8-4c43-8fb5-6d5288a7cdf8](https://s2.loli.net/2022/10/03/QSxqJLwiNM1nZlO.jpg) - -而Map就是为了实现这种数据结构而存在的,我们通过保存键值对的形式来存储映射关系,就可以轻松地通过键找到对应的映射值,比如现在我们要保存很多学生的信息,而这些学生都有自己的ID,我们可以将其以映射的形式保存,将ID作为键,学生详细信息作为值,这样我们就可以通过学生的ID快速找到对应学生的信息了。 - -![image-20221003213157956](https://s2.loli.net/2022/10/03/i2x6m3hzFC5GIAd.png) - -在Map中,这些映射关系被存储为键值对,我们先来看看Map接口中定义了哪些操作: - -```java -//Map并不是Collection体系下的接口,而是单独的一个体系,因为操作特殊 -//这里需要填写两个泛型参数,其中K就是键的类型,V就是值的类型,比如上面的学生信息,ID一般是int,那么键就是Integer类型的,而值就是学生信息,所以说值是学生对象类型的 -public interface Map { - //-------- 查询相关操作 -------- - - //获取当前存储的键值对数量 - int size(); - - //是否为空 - boolean isEmpty(); - - //查看Map中是否包含指定的键 - boolean containsKey(Object key); - - //查看Map中是否包含指定的值 - boolean containsValue(Object value); - - //通过给定的键,返回其映射的值 - V get(Object key); - - //-------- 修改相关操作 -------- - - //向Map中添加新的映射关系,也就是新的键值对 - V put(K key, V value); - - //根据给定的键,移除其映射关系,也就是移除对应的键值对 - V remove(Object key); - - - //-------- 批量操作 -------- - - //将另一个Map中的所有键值对添加到当前Map中 - void putAll(Map m); - - //清空整个Map - void clear(); - - - //-------- 其他视图操作 -------- - - //返回Map中存放的所有键,以Set形式返回 - Set keySet(); - - //返回Map中存放的所有值 - Collection values(); - - //返回所有的键值对,这里用的是内部类Entry在表示 - Set> entrySet(); - - //这个是内部接口Entry,表示一个键值对 - interface Entry { - //获取键值对的键 - K getKey(); - - //获取键值对的值 - V getValue(); - - //修改键值对的值 - V setValue(V value); - - //判断两个键值对是否相等 - boolean equals(Object o); - - //返回当前键值对的哈希值 - int hashCode(); - - ... - } - - ... -} -``` - -当然,Map中定义了非常多的方法,尤其是在Java 8之后新增的大量方法,我们会在后面逐步介绍的。 - -我们可以来尝试使用一下Map,实际上非常简单,这里我们使用最常见的HashMap,它的底层采用哈希表实现: - -```java -public static void main(String[] args) { - Map map = new HashMap<>(); - map.put(1, "小明"); //使用put方法添加键值对,返回值我们会在后面讨论 - map.put(2, "小红"); - System.out.println(map.get(2)); //使用get方法根据键获取对应的值 -} -``` - -![image-20221003214807048](https://s2.loli.net/2022/10/03/8Fl6YizINQP9dmX.png) - -注意,Map中无法添加相同的键,同样的键只能存在一个,即使值不同。如果出现键相同的情况,那么会覆盖掉之前的: - -```java -public static void main(String[] args) { - Map map = new HashMap<>(); - map.put(1, "小明"); - map.put(1, "小红"); //这里的键跟之前的是一样的,这样会导致将之前的键值对覆盖掉 - System.out.println(map.get(1)); -} -``` - -![image-20221003214807048](https://s2.loli.net/2022/10/03/8Fl6YizINQP9dmX.png) - -为了防止意外将之前的键值对覆盖掉,我们可以使用: - -```java -public static void main(String[] args) { - Map map = new HashMap<>(); - map.put(1, "小明"); - map.putIfAbsent(1, "小红"); //Java8新增操作,只有在不存在相同键的键值对时才会存放 - System.out.println(map.get(1)); -} -``` - -还有,我们在获取一个不存在的映射时,默认会返回null作为结果: - -```java -public static void main(String[] args) { - Map map = new HashMap<>(); - map.put(1, "小明"); //Map中只有键为1的映射 - System.out.println(map.get(3)); //此时获取键为3的值,那肯定是没有的,所以说返回null -} -``` - -我们也可以为这种情况添加一个预备方案,当Map中不存在时,可以返回一个备选的返回值: - -```java -public static void main(String[] args) { - Map map = new HashMap<>(); - map.put(1, "小明"); - System.out.println(map.getOrDefault(3, "备胎")); //Java8新增操作,当不存在对应的键值对时,返回备选方案 -} -``` - -同样的,因为HashMap底层采用哈希表实现,所以不维护顺序,我们在获取所有键和所有值时,可能会是乱序的: - -```java -public static void main(String[] args) { - Map map = new HashMap<>(); - map.put("0", "十七张"); - map.put("+", "牌"); - map.put("P", "你能秒我"); - System.out.println(map); - System.out.println(map.keySet()); - System.out.println(map.values()); -} -``` - -![image-20221003220156062](https://s2.loli.net/2022/10/03/DNXqwk3UOPnMmlc.png) - -如果需要维护顺序,我们同样可以使用LinkedHashMap,它的内部对插入顺序进行了维护: - -```java -public static void main(String[] args) { - Map map = new LinkedHashMap<>(); - map.put("0", "十七张"); - map.put("+", "牌"); - map.put("P", "你能秒我"); - System.out.println(map); - System.out.println(map.keySet()); - System.out.println(map.values()); -} -``` - -![image-20221003220458539](https://s2.loli.net/2022/10/03/QHkWZsFvzASpxqL.png) - -实际上Map的使用还是挺简单的,我们接着来看看Map的底层是如何实现的,首先是最简单的HashMap,我们前面已经说过了,它的底层采用的是哈希表,首先回顾我们之前学习的哈希表,我们当时说了,哈希表可能会出现哈希冲突,这样保存的元素数量就会存在限制,而我们可以通过连地址法解决这种问题,最后哈希表就长这样了: - -![image-20220820221104298](https://s2.loli.net/2022/09/30/kr4CcVEwI72AiDU.png) - -实际上这个表就是一个存放头结点的数组+若干结点,而HashMap也是这样的,我们来看看这里面是怎么定义的: - -```java -public class HashMap extends AbstractMap - implements Map, Cloneable, Serializable { - - ... - - static class Node implements Map.Entry { //内部使用结点,实际上就是存放的映射关系 - final int hash; - final K key; //跟我们之前不一样,我们之前一个结点只有键,而这里的结点既存放键也存放值,当然计算哈希还是使用键 - V value; - Node next; - ... - } - - ... - - transient Node[] table; //这个就是哈希表本体了,可以看到跟我们之前的写法是一样的,也是头结点数组,只不过HashMap中没有设计头结点(相当于没有头结点的链表) - - final float loadFactor; //负载因子,这个东西决定了HashMap的扩容效果 - - public HashMap() { - this.loadFactor = DEFAULT_LOAD_FACTOR; //当我们创建对象时,会使用默认的负载因子,值为0.75 - } - - ... -} -``` - -可以看到,实际上底层大致结构跟我们之前学习的差不多,只不过多了一些特殊的东西: - -* HashMap支持自动扩容,哈希表的大小并不是一直不变的,否则太过死板 -* HashMap并不是只使用简单的链地址法,当链表长度到达一定限制时,会转变为效率更高的红黑树结构 - -我们来研究一下它的put方法: - -```java -public V put(K key, V value) { - //这里计算完键的哈希值之后,调用的另一个方法进行映射关系存放 - return putVal(hash(key), key, value, false, true); -} - -final V putVal(int hash, K key, V value, boolean onlyIfAbsent, - boolean evict) { - Node[] tab; Node p; int n, i; - if ((tab = table) == null || (n = tab.length) == 0) //如果底层哈希表没初始化,先初始化 - n = (tab = resize()).length; //通过resize方法初始化底层哈希表,初始容量为16,后续会根据情况扩容,底层哈希表的长度永远是2的n次方 - //因为传入的哈希值可能会很大,这里同样是进行取余操作 - //(n - 1) & hash 等价于 hash % n 这里的i就是最终得到的下标位置了 - if ((p = tab[i = (n - 1) & hash]) == null) - tab[i] = newNode(hash, key, value, null); //如果这个位置上什么都没有,那就直接放一个新的结点 - else { //这种情况就是哈希冲突了 - Node e; K k; - if (p.hash == hash && //如果上来第一个结点的键的哈希值跟当前插入的键的哈希值相同,键也相同,说明已经存放了相同键的键值对了,那就执行覆盖操作 - ((k = p.key) == key || (key != null && key.equals(k)))) - e = p; //这里直接将待插入结点等于原本冲突的结点,一会直接覆盖 - else if (p instanceof TreeNode) //如果第一个结点是TreeNode类型的,说明这个链表已经升级为红黑树了 - e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value); //在红黑树中插入新的结点 - else { - for (int binCount = 0; ; ++binCount) { //普通链表就直接在链表尾部插入 - if ((e = p.next) == null) { - p.next = newNode(hash, key, value, null); //找到尾部,直接创建新的结点连在后面 - if (binCount >= TREEIFY_THRESHOLD - 1) //如果当前链表的长度已经很长了,达到了阈值 - treeifyBin(tab, hash); //那么就转换为红黑树来存放 - break; //直接结束 - } - if (e.hash == hash && - ((k = e.key) == key || (key != null && key.equals(k)))) //同样的,如果在向下找的过程中发现已经存在相同键的键值对了,直接结束,让p等于e一会覆盖就行了 - break; - p = e; - } - } - if (e != null) { // 如果e不为空,只有可能是前面出现了相同键的情况,其他情况e都是null,所有直接覆盖就行 - V oldValue = e.value; - if (!onlyIfAbsent || oldValue == null) - e.value = value; - afterNodeAccess(e); - return oldValue; //覆盖之后,会返回原本的被覆盖值 - } - } - ++modCount; - if (++size > threshold) //键值对size计数自增,如果超过阈值,会对底层哈希表数组进行扩容 - resize(); //调用resize进行扩容 - afterNodeInsertion(evict); - return null; //正常插入键值对返回值为null -} -``` - -是不是感觉只要前面的数据结构听懂了,这里简直太简单。根据上面的推导,我们在正常插入一个键值对时,会得到null返回值,而冲突时会得到一个被覆盖的值: - -```java -public static void main(String[] args) { - Map map = new HashMap<>(); - System.out.println(map.put("0", "十七张")); - System.out.println(map.put("0", "慈善家")); -} -``` - -![image-20221003224137177](https://s2.loli.net/2022/10/03/A2rXocbU9StlDOC.png) - -现在我们知道,当HashMap的一个链表长度过大时,会自动转换为红黑树: - -![710c1c38-95a8-493d-8645-067b991af908](https://s2.loli.net/2022/10/03/E7GnIVjPAwf8Fol.jpg) - -但是这样始终治标不治本,受限制的始终是底层哈希表的长度,我们还需要进一步对底层的这个哈希表进行扩容才可以从根本上解决问题,我们来看看`resize()`方法: - -```java -final Node[] resize() { - Node[] oldTab = table; //先把下面这几个旧的东西保存一下 - int oldCap = (oldTab == null) ? 0 : oldTab.length; - int oldThr = threshold; - int newCap, newThr = 0; //这些是新的容量和扩容阈值 - if (oldCap > 0) { //如果旧容量大于0,那么就开始扩容 - if (oldCap >= MAXIMUM_CAPACITY) { //如果旧的容量已经大于最大限制了,那么直接给到 Integer.MAX_VALUE - threshold = Integer.MAX_VALUE; - return oldTab; //这种情况不用扩了 - } - else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && - oldCap >= DEFAULT_INITIAL_CAPACITY) //新的容量等于旧容量的2倍,同样不能超过最大值 - newThr = oldThr << 1; //新的阈值也提升到原来的两倍 - } - else if (oldThr > 0) // 旧容量不大于0只可能是还没初始化,这个时候如果阈值大于0,直接将新的容量变成旧的阈值 - newCap = oldThr; - else { // 默认情况下阈值也是0,也就是我们刚刚无参new出来的时候 - newCap = DEFAULT_INITIAL_CAPACITY; //新的容量直接等于默认容量16 - newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); //阈值为负载因子乘以默认容量,负载因子默认为0.75,也就是说只要整个哈希表用了75%的容量,那么就进行扩容,至于为什么默认是0.75,原因很多,这里就不解释了,反正作为新手,这些都是大佬写出来的,我们用就完事。 - } - ... - threshold = newThr; - @SuppressWarnings({"rawtypes","unchecked"}) - Node[] newTab = (Node[])new Node[newCap]; - table = newTab; //将底层数组变成新的扩容之后的数组 - if (oldTab != null) { //如果旧的数组不为空,那么还需要将旧的数组中所有元素全部搬到新的里面去 - ... //详细过程就不介绍了 - } -} -``` - -是不是感觉自己有点了解HashMap的运作机制了,其实并不是想象中的那么难,因为这些东西再怎么都是人写的。 - -而LinkedHashMap是直接继承自HashMap,具有HashMap的全部性质,同时得益于每一个节点都是一个双向链表,在插入键值对时,同时保存了插入顺序: - -```java -static class Entry extends HashMap.Node { //LinkedHashMap中的结点实现 - Entry before, after; //这里多了一个指向前一个结点和后一个结点的引用 - Entry(int hash, K key, V value, Node next) { - super(hash, key, value, next); - } -} -``` - -这样我们在遍历LinkedHashMap时,顺序就同我们的插入顺序一致。当然,也可以使用访问顺序,也就是说对于刚访问过的元素,会被排到最后一位。 - -当然还有一种比较特殊的Map叫做TreeMap,就像它的名字一样,就是一个Tree,它的内部直接维护了一个红黑树(没有使用哈希表)因为它会将我们插入的结点按照规则进行排序,所以说直接采用红黑树会更好,我们在创建时,直接给予一个比较规则即可,跟之前的TreeSet是一样的: - -```java -public static void main(String[] args) { - Map map = new TreeMap<>((a, b) -> b - a); - map.put(0, "单走"); - map.put(1, "一个六"); - map.put(3, "**"); - System.out.println(map); -} -``` - -![image-20221003231135805](https://s2.loli.net/2022/10/03/2oJXBui5aD8q1Gh.png) - -现在我们倒回来看之前讲解的HashSet集合,实际上它的底层很简单: - -```java -public class HashSet - extends AbstractSet - implements Set, Cloneable, java.io.Serializable -{ - - private transient HashMap map; //对,你没看错,底层直接用map来做事 - - // 因为Set只需要存储Key就行了,所以说这个对象当做每一个键值对的共享Value - private static final Object PRESENT = new Object(); - - //直接构造一个默认大小为16负载因子0.75的HashMap - public HashSet() { - map = new HashMap<>(); - } - - ... - - //你会发现所有的方法全是替身攻击 - public Iterator iterator() { - return map.keySet().iterator(); - } - - public int size() { - return map.size(); - } - - public boolean isEmpty() { - return map.isEmpty(); - } -} -``` - -通过观察HashSet的源码发现,HashSet几乎都在操作内部维护的一个HashMap,也就是说,HashSet只是一个表壳,而内部维护的HashMap才是灵魂!就像你进了公司,在外面花钱请别人帮你写公司的业务,你只需要坐着等别人写好然后你自己拿去交差就行了。所以说,HashSet利用了HashMap内部的数据结构,轻松地就实现了Set定义的全部功能! - -再来看TreeSet,实际上用的就是我们的TreeMap: - -```java -public class TreeSet extends AbstractSet - implements NavigableSet, Cloneable, java.io.Serializable -{ - //底层需要一个NavigableMap,就是自动排序的Map - private transient NavigableMap m; - - //不用我说了吧 - private static final Object PRESENT = new Object(); - - ... - - //直接使用TreeMap解决问题 - public TreeSet() { - this(new TreeMap()); - } - - ... -} -``` - -同理,这里就不多做阐述了。 - -我们接着来看看Map中定义的哪些杂七杂八的方法,首先来看看`compute`方法: - -```java -public static void main(String[] args) { - Map map = new HashMap<>(); - map.put(1, "A"); - map.put(2, "B"); - map.compute(1, (k, v) -> { //compute会将指定Key的值进行重新计算,若Key不存在,v会返回null - return v+"M"; //这里返回原来的value+M - }); - map.computeIfPresent(1, (k, v) -> { //当Key存在时存在则计算并赋予新的值 - return v+"M"; //这里返回原来的value+M - }); - System.out.println(map); -} -``` - -也可以使用`computeIfAbsent`,当不存在Key时,计算并将键值对放入Map中: - -```java -public static void main(String[] args) { - Map map = new HashMap<>(); - map.put(1, "A"); - map.put(2, "B"); - map.computeIfAbsent(0, (k) -> { //若不存在则计算并插入新的值 - return "M"; //这里返回M - }); - System.out.println(map); -} -``` - -merge方法用于处理数据: - -```java -public static void main(String[] args) { - List students = Arrays.asList( - new Student("yoni", "English", 80), - new Student("yoni", "Chiness", 98), - new Student("yoni", "Math", 95), - new Student("taohai.wang", "English", 50), - new Student("taohai.wang", "Chiness", 72), - new Student("taohai.wang", "Math", 41), - new Student("Seely", "English", 88), - new Student("Seely", "Chiness", 89), - new Student("Seely", "Math", 92) - ); - Map scoreMap = new HashMap<>(); - //merge方法可以对重复键的值进行特殊操作,比如我们想计算某个学生的所有科目分数之后,那么就可以像这样: - students.forEach(student -> scoreMap.merge(student.getName(), student.getScore(), Integer::sum)); - scoreMap.forEach((k, v) -> System.out.println("key:" + k + "总分" + "value:" + v)); -} - -static class Student { - private final String name; - private final String type; - private final int score; - - public Student(String name, String type, int score) { - this.name = name; - this.type = type; - this.score = score; - } - - public String getName() { - return name; - } - - public int getScore() { - return score; - } - - public String getType() { - return type; - } -} -``` - -`replace`方法可以快速替换某个映射的值: - -```java -public static void main(String[] args) { - Map map = new HashMap<>(); - map.put(0, "单走"); - map.replace(0, ">>>"); //直接替换为新的 - System.out.println(map); -} -``` - -也可以精准匹配: - -```java -public static void main(String[] args) { - Map map = new HashMap<>(); - map.put(0, "单走"); - map.replace(0, "巴卡", "玛卡"); //只有键和值都匹配时,才进行替换 - System.out.println(map); -} -``` - -包括remove方法,也支持键值同时匹配: - -```java -public static void main(String[] args) { - Map map = new HashMap<>(); - map.put(0, "单走"); - map.remove(0, "单走"); //只有同时匹配时才移除 - System.out.println(map); -} -``` - -是不是感觉学习了Map之后,涨了不少姿势? - -### Stream流 - -Java 8 API添加了一个新的抽象称为流Stream,可以让你以一种声明的方式处理数据。Stream 使用一种类似用 SQL 语句从数据库查询数据的直观方式来提供一种对 Java 集合运算和表达的高阶抽象。Stream API可以极大提高Java程序员的生产力,让程序员写出高效率、干净、简洁的代码。这种风格将要处理的元素集合看作一种流, 流在管道中传输, 并且可以在管道的节点上进行处理, 比如筛选, 排序,聚合等。元素流在管道中经过中间操作(intermediate operation)的处理,最后由最终操作(terminal operation)得到前面处理的结果。 - -![image-20221003232832897](https://s2.loli.net/2022/10/03/r4AtmVRZ51y7uxd.png) - -它看起来就像一个工厂的流水线一样!我们就可以把一个Stream当做流水线处理: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(); - list.add("A"); - list.add("B"); - list.add("C"); - - //移除为B的元素 - Iterator iterator = list.iterator(); - while (iterator.hasNext()){ - if(iterator.next().equals("B")) iterator.remove(); - } - - //Stream操作 - list = list //链式调用 - .stream() //获取流 - .filter(e -> !e.equals("B")) //只允许所有不是B的元素通过流水线 - .collect(Collectors.toList()); //将流水线中的元素重新收集起来,变回List - System.out.println(list); -} -``` - -可能从上述例子中还不能感受到流处理带来的便捷,我们通过下面这个例子来感受一下: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(); - list.add(1); - list.add(2); - list.add(3); - list.add(3); - - list = list - .stream() - .distinct() //去重(使用equals判断) - .sorted((a, b) -> b - a) //进行倒序排列 - .map(e -> e+1) //每个元素都要执行+1操作 - .limit(2) //只放行前两个元素 - .collect(Collectors.toList()); - - System.out.println(list); -} -``` - -当遇到大量的复杂操作时,我们就可以使用Stream来快速编写代码,这样不仅代码量大幅度减少,而且逻辑也更加清晰明了(如果你学习过SQL的话,你会发现它更像一个Sql语句) - -**注意**:不能认为每一步是直接依次执行的!我们可以断点测试一下: - -```java -List list = new ArrayList<>(); -list.add(1); -list.add(2); -list.add(3); -list.add(3); - -list = list - .stream() - .distinct() //断点 - .sorted((a, b) -> b - a) - .map(e -> { - System.out.println(">>> "+e); //断点 - return e+1; - }) - .limit(2) //断点 - .collect(Collectors.toList()); -``` - -实际上,stream会先记录每一步操作,而不是直接开始执行内容,当整个链式调用完成后,才会依次进行,也就是说需要的时候,工厂的机器才会按照预定的流程启动。 - -接下来,我们用一堆随机数来进行更多流操作的演示: - -```java -public static void main(String[] args) { - Random random = new Random(); //没想到吧,Random支持直接生成随机数的流 - random - .ints(-100, 100) //生成-100~100之间的,随机int型数字(本质上是一个IntStream) - .limit(10) //只获取前10个数字(这是一个无限制的流,如果不加以限制,将会无限进行下去!) - .filter(i -> i < 0) //只保留小于0的数字 - .sorted() //默认从小到大排序 - .forEach(System.out::println); //依次打印 -} -``` - -我们可以生成一个统计实例来帮助我们快速进行统计: - -```java -public static void main(String[] args) { - Random random = new Random(); //Random是一个随机数工具类 - IntSummaryStatistics statistics = random - .ints(0, 100) - .limit(100) - .summaryStatistics(); //获取语法统计实例 - System.out.println(statistics.getMax()); //快速获取最大值 - System.out.println(statistics.getCount()); //获取数量 - System.out.println(statistics.getAverage()); //获取平均值 -} -``` - -普通的List只需要一个方法就可以直接转换到方便好用的IntStream了: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(); - list.add(1); - list.add(1); - list.add(2); - list.add(3); - list.add(4); - list.stream() - .mapToInt(i -> i) //将每一个元素映射为Integer类型(这里因为本来就是Integer) - .summaryStatistics(); -} -``` - -我们还可以通过`flat`来对整个流进行进一步细分: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(); - list.add("A,B"); - list.add("C,D"); - list.add("E,F"); //我们想让每一个元素通过,进行分割,变成独立的6个元素 - list = list - .stream() //生成流 - .flatMap(e -> Arrays.stream(e.split(","))) //分割字符串并生成新的流 - .collect(Collectors.toList()); //汇成新的List - System.out.println(list); //得到结果 -} -``` - -我们也可以只通过Stream来完成所有数字的和,使用`reduce`方法: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(); - list.add(1); - list.add(2); - list.add(3); - int sum = list - .stream() - .reduce((a, b) -> a + b) //计算规则为:a是上一次计算的值,b是当前要计算的参数,这里是求和 - .get(); //我们发现得到的是一个Optional类实例,通过get方法返回得到的值 - System.out.println(sum); -} -``` - -可能,作为新手来说,一次性无法接受这么多内容,但是在各位以后的开发中,就会慢慢使用到这些东西了。 - -### Collections工具类 - -我们在前面介绍了Arrays,它是一个用于操作数组的工具类,它给我们提供了大量的工具方法。 - -既然数组操作都这么方便了,集合操作能不能也安排点高级的玩法呢?那必须的,JDK为我们准备的Collocations类就是专用于集合的工具类,比如我们想快速求得List中的最大值和最小值: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(); - Collections.max(list); - Collections.min(list); -} -``` - -同样的,我们可以对一个集合进行二分搜索(注意,集合的具体类型,必须是实现Comparable接口的类): - -```java -public static void main(String[] args) { - List list = Arrays.asList(2, 3, 8, 9, 10, 13); - System.out.println(Collections.binarySearch(list, 8)); -} -``` - -我们也可以对集合的元素进行快速填充,注意这个填充是对集合中已有的元素进行覆盖: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(Arrays.asList(1,2,3,4,5)); - Collections.fill(list, 6); - System.out.println(list); -} -``` - -如果集合中本身没有元素,那么`fill`操作不会生效。 - -有些时候我们可能需要生成一个空的集合类返回,那么我们可以使用`emptyXXX`来快速生成一个只读的空集合: - -```java -public static void main(String[] args) { - List list = Collections.emptyList(); - //Collections.singletonList() 会生成一个只有一个元素的List - list.add(10); //不支持,会直接抛出异常 -} -``` - -我们也可以将一个可修改的集合变成只读的集合: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(Arrays.asList(1,2,3,4,5)); - List newList = Collections.unmodifiableList(list); - newList.add(10); //不支持,会直接抛出异常 -} -``` - -我们也可以寻找子集合的位置: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(Arrays.asList(1,2,3,4,5)); - System.out.println(Collections.indexOfSubList(list, Arrays.asList(4, 5))); -} -``` - -得益于泛型的类型擦除机制,实际上最后只要是Object的实现类都可以保存到集合类中,那么就会出现这种情况: - -```java -public static void main(String[] args) { - //使用原始类型接收一个Integer类型的ArrayList - List list = new ArrayList<>(Arrays.asList(1,2,3,4,5)); - list.add("aaa"); //我们惊奇地发现,这玩意居然能存字符串进去 - System.out.println(list); -} -``` - -![image-20221004001007854](https://s2.loli.net/2022/10/04/FP5z3X8SEMkGYtT.png) - -没错,由于泛型机制上的一些漏洞,实际上对应类型的集合类有可能会存放其他类型的值,泛型的类型检查只存在于编译阶段,只要我们绕过这个阶段,在实际运行时,并不会真的进行类型检查,要解决这种问题很简单,就是在运行时进行类型检查: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(Arrays.asList(1,2,3,4,5)); - list = Collections.checkedList(list, Integer.class); //这里的.class关键字我们会在后面反射中介绍,表示Integer这个类型 - list.add("aaa"); - System.out.println(list); -} -``` - -`checkedXXX`可以将给定集合类进行包装,在运行时同样会进行类型检查,如果通过上面的漏洞插入一个本不应该是当前类型集合支持的类型,那么会直接抛出类型转换异常: - -![image-20221004001409799](https://s2.loli.net/2022/10/04/5BHq1u9JU3bhdI6.png) - -是不是感觉这个工具类好像还挺好用的?实际上在我们的开发中,这个工具类也经常被使用到。 - -*** - -## Java I/O - -**注意:**这块会涉及到**操作系统**和**计算机组成原理**相关内容。 - -I/O简而言之,就是输入输出,那么为什么会有I/O呢?其实I/O无时无刻都在我们的身边,比如读取硬盘上的文件,网络文件传输,鼠标键盘输入,也可以是接受单片机发回的数据,而能够支持这些操作的设备就是I/O设备。 - -我们可以大致看一下整个计算机的总线结构: - -![image-20221004002405375](https://s2.loli.net/2022/10/04/Q8JGeMprkgHsnPY.png) - -常见的I/O设备一般是鼠标、键盘这类通过USB进行传输的外设或者是通过Sata接口或是M.2连接的硬盘。一般情况下,这些设备是由CPU发出指令通过南桥芯片间接进行控制,而不是由CPU直接操作。 - -而我们在程序中,想要读取这些外部连接的I/O设备中的内容,就需要将数据传输到内存中。而需要实现这样的操作,单单凭借一个小的程序是无法做到的,而操作系统(如:Windows/Linux/MacOS)就是专门用于控制和管理计算机硬件和软件资源的软件,我们需要读取一个IO设备的内容时,就可以向操作系统发出请求,由操作系统帮助我们来和底层的硬件交互以完成我们的读取/写入请求。 - -从读取硬盘文件的角度来说,不同的操作系统有着不同的文件系统(也就是文件在硬盘中的存储排列方式,如Windows就是NTFS、MacOS就是APFS),硬盘只能存储一个个0和1这样的二进制数据,至于0和1如何排列,各自又代表什么意思,就是由操作系统的文件系统来决定的。从网络通信角度来说,网络信号通过网卡等设备翻译为二进制信号,再交给系统进行读取,最后再由操作系统来给到程序。 - -![image-20221004002733950](https://s2.loli.net/2022/10/04/13h7yTekm2FfnRw.png) - -(传统的SATA硬盘就是通过SATA线与电脑主板相连,这样才可以读取到数据) - -JDK提供了一套用于IO操作的框架,为了方便我们开发者使用,就定义了一个像水流一样,根据流的传输方向和读取单位,分为字节流InputStream和OutputStream以及字符流Reader和Writer的IO框架,当然,这里的Stream并不是前面集合框架认识的Stream,这里的流指的是数据流,通过流,我们就可以一直从流中读取数据,直到读取到尽头,或是不断向其中写入数据,直到我们写入完成,而这类IO就是我们所说的BIO, - -字节流一次读取一个字节,也就是一个`byte`的大小,而字符流顾名思义,就是一次读取一个字符,也就是一个`char`的大小(在读取纯文本文件的时候更加适合),有关这两种流,会在后面详细介绍,这个章节我们需要学习16个关键的流。 - -### 文件字节流 - -要学习和使用IO,首先就要从最易于理解的读取文件开始说起。 - -首先介绍一下FileInputStream,我们可以通过它来获取文件的输入流: - -```java -public static void main(String[] args) { - try { //注意,IO相关操作会有很多影响因素,有可能出现异常,所以需要明确进行处理 - FileInputStream inputStream = new FileInputStream("路径"); - //路径支持相对路径和绝对路径 - } catch (FileNotFoundException e) { - e.printStackTrace(); - } -} -``` - -相对路径是在当前运行目录(就是你在哪个目录运行java命令启动Java程序的)的路径下寻找文件,而绝对路径,是从根目录开始寻找。路径分割符支持使用`/`或是`\\`,但是不能写为`\`因为它是转义字符!比如在Windows下: - -``` -C://User/lbw/nb 这个就是一个绝对路径,因为是从盘符开始的 -test/test 这个就是一个相对路径,因为并不是从盘符开始的,而是一个直接的路径 -``` - -在Linux和MacOS下: - -``` -/root/tmp 这个就是一个绝对路径,绝对路径以/开头 -test/test 这个就是一个相对路径,不是以/开头的 -``` - -当然,这个其实还是很好理解的,我们在使用时注意一下就行了。 - -在使用完成一个流之后,必须关闭这个流来完成对资源的释放,否则资源会被一直占用: - -```java -public static void main(String[] args) { - FileInputStream inputStream = null; //定义可以先放在try外部 - try { - inputStream = new FileInputStream("路径"); - } catch (FileNotFoundException e) { - e.printStackTrace(); - } finally { - try { //建议在finally中进行,因为关闭流是任何情况都必须要执行的! - if(inputStream != null) inputStream.close(); - } catch (IOException e) { - e.printStackTrace(); - } - } -} -``` - -虽然这样的写法才是最保险的,但是显得过于繁琐了,尤其是finally中再次嵌套了一个try-catch块,因此在JDK1.7新增了try-with-resource语法,用于简化这样的写法(本质上还是和这样的操作一致,只是换了个写法) - -```java -public static void main(String[] args) { - - //注意,这种语法只支持实现了AutoCloseable接口的类! - try(FileInputStream inputStream = new FileInputStream("路径")) { //直接在try()中定义要在完成之后释放的资源 - - } catch (IOException e) { //这里变成IOException是因为调用close()可能会出现,而FileNotFoundException是继承自IOException的 - e.printStackTrace(); - } - //无需再编写finally语句块,因为在最后自动帮我们调用了close() -} -``` - -之后为了方便,我们都使用此语法进行教学。 - -现在我们拿到了文件的输入流,那么怎么才能读取文件里面的内容呢?我们可以使用`read`方法: - -```java -public static void main(String[] args) { - //test.txt:a - try(FileInputStream inputStream = new FileInputStream("test.txt")) { - //使用read()方法进行字符读取 - System.out.println((char) inputStream.read()); //读取一个字节的数据(英文字母只占1字节,中文占2字节) - System.out.println(inputStream.read()); //唯一一个字节的内容已经读完了,再次读取返回-1表示没有内容了 - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -使用read可以直接读取一个字节的数据,注意,流的内容是有限的,读取一个少一个。我们如果想一次性全部读取的话,可以直接使用一个while循环来完成: - -```java -public static void main(String[] args) { - //test.txt:abcd - try(FileInputStream inputStream = new FileInputStream("test.txt")) { - int tmp; - while ((tmp = inputStream.read()) != -1){ //通过while循环来一次性读完内容 - System.out.println((char)tmp); - } - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -使用`available`方法能查看当前可读的剩余字节数量(注意:并不一定真实的数据量就是这么多,尤其是在网络I/O操作时,这个方法只能进行一个预估也可以说是暂时能一次性可以读取的数量,当然在磁盘IO下,一般情况都是真实的数据量) - -```java -try(FileInputStream inputStream = new FileInputStream("test.txt")) { - System.out.println(inputStream.available()); //查看剩余数量 -}catch (IOException e){ - e.printStackTrace(); -} -``` - -当然,一个一个读取效率太低了,那能否一次性全部读取呢?我们可以预置一个合适容量的byte[]数组来存放: - -```java -public static void main(String[] args) { - //test.txt:abcd - try(FileInputStream inputStream = new FileInputStream("test.txt")) { - byte[] bytes = new byte[inputStream.available()]; //我们可以提前准备好合适容量的byte数组来存放 - System.out.println(inputStream.read(bytes)); //一次性读取全部内容(返回值是读取的字节数) - System.out.println(new String(bytes)); //通过String(byte[])构造方法得到字符串 - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -也可以控制要读取数量: - -```java -System.out.println(inputStream.read(bytes, 1, 2)); //第二个参数是从给定数组的哪个位置开始放入内容,第三个参数是读取流中的字节数 -``` - -**注意**:一次性读取同单个读取一样,当没有任何数据可读时,依然会返回-1 - -通过`skip()`方法可以跳过指定数量的字节: - -```java -public static void main(String[] args) { - //test.txt:abcd - try(FileInputStream inputStream = new FileInputStream("test.txt")) { - System.out.println(inputStream.skip(1)); - System.out.println((char) inputStream.read()); //跳过了一个字节 - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -注意:FileInputStream是不支持`reset()`的,虽然有这个方法,但是这里先不提及。 - -既然有输入流,那么文件输出流也是必不可少的: - -```java -public static void main(String[] args) { - //输出流也需要在最后调用close()方法,并且同样支持try-with-resource - try(FileOutputStream outputStream = new FileOutputStream("output.txt")) { - //注意:若此文件不存在,会直接创建这个文件! - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -输出流没有`read()`操作而是`write()`操作,使用方法同输入流一样,只不过现在的方向变为我们向文件里写入内容: - -```java -public static void main(String[] args) { - try(FileOutputStream outputStream = new FileOutputStream("output.txt")) { - outputStream.write('c'); //同read一样,可以直接写入内容 - outputStream.write("lbwnb".getBytes()); //也可以直接写入byte[] - outputStream.write("lbwnb".getBytes(), 0, 1); //同上输入流 - outputStream.flush(); //建议在最后执行一次刷新操作(强制写入)来保证数据正确写入到硬盘文件中 - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -那么如果是我只想在文件尾部进行追加写入数据呢?我们可以调用另一个构造方法来实现: - -```java -public static void main(String[] args) { - try(FileOutputStream outputStream = new FileOutputStream("output.txt", true)) { //true表示开启追加模式 - outputStream.write("lb".getBytes()); //现在只会进行追加写入,而不是直接替换原文件内容 - outputStream.flush(); - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -利用输入流和输出流,就可以轻松实现文件的拷贝了: - -```java -public static void main(String[] args) { - try(FileOutputStream outputStream = new FileOutputStream("output.txt"); - FileInputStream inputStream = new FileInputStream("test.txt")) { //可以写入多个 - byte[] bytes = new byte[10]; //使用长度为10的byte[]做传输媒介 - int tmp; //存储本地读取字节数 - while ((tmp = inputStream.read(bytes)) != -1){ //直到读取完成为止 - outputStream.write(bytes, 0, tmp); //写入对应长度的数据到输出流 - } - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -### 文件字符流 - -字符流不同于字节,字符流是以一个具体的字符进行读取,因此它只适合读纯文本的文件,如果是其他类型的文件不适用: - -```java -public static void main(String[] args) { - try(FileReader reader = new FileReader("test.txt")){ - reader.skip(1); //现在跳过的是一个字符 - System.out.println((char) reader.read()); //现在是按字符进行读取,而不是字节,因此可以直接读取到中文字符 - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -同理,字符流只支持`char[]`类型作为存储: - -```java -public static void main(String[] args) { - try(FileReader reader = new FileReader("test.txt")){ - char[] str = new char[10]; - reader.read(str); - System.out.println(str); //直接读取到char[]中 - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -既然有了Reader肯定也有Writer: - -```java -public static void main(String[] args) { - try(FileWriter writer = new FileWriter("output.txt")){ - writer.getEncoding(); //支持获取编码(不同的文本文件可能会有不同的编码类型) - writer.write('牛'); - writer.append('牛'); //其实功能和write一样 - writer.flush(); //刷新 - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -我们发现不仅有`write()`方法,还有一个`append()`方法,但是实际上他们效果是一样的,看源码: - -```java -public Writer append(char c) throws IOException { - write(c); - return this; -} -``` - -append支持像StringBuilder那样的链式调用,返回的是Writer对象本身。 - -**练习**:尝试一下用Reader和Writer来拷贝纯文本文件。 - -这里需要额外介绍一下File类,它是专门用于表示一个文件或文件夹,只不过它只是代表这个文件,但并不是这个文件本身。通过File对象,可以更好地管理和操作硬盘上的文件。 - -```java -public static void main(String[] args) { - File file = new File("test.txt"); //直接创建文件对象,可以是相对路径,也可以是绝对路径 - System.out.println(file.exists()); //此文件是否存在 - System.out.println(file.length()); //获取文件的大小 - System.out.println(file.isDirectory()); //是否为一个文件夹 - System.out.println(file.canRead()); //是否可读 - System.out.println(file.canWrite()); //是否可写 - System.out.println(file.canExecute()); //是否可执行 -} -``` - -通过File对象,我们就能快速得到文件的所有信息,如果是文件夹,还可以获取文件夹内部的文件列表等内容: - -```java -File file = new File("/"); -System.out.println(Arrays.toString(file.list())); //快速获取文件夹下的文件名称列表 -for (File f : file.listFiles()){ //所有子文件的File对象 - System.out.println(f.getAbsolutePath()); //获取文件的绝对路径 -} -``` - -如果我们希望读取某个文件的内容,可以直接将File作为参数传入字节流或是字符流: - -```java -File file = new File("test.txt"); -try (FileInputStream inputStream = new FileInputStream(file)){ //直接做参数 - System.out.println(inputStream.available()); -}catch (IOException e){ - e.printStackTrace(); -} -``` - -**练习**:尝试拷贝文件夹下的所有文件到另一个文件夹 - -### 缓冲流 - -虽然普通的文件流读取文件数据非常便捷,但是每次都需要从外部I/O设备去获取数据,由于外部I/O设备的速度一般都达不到内存的读取速度,很有可能造成程序反应迟钝,因此性能还不够高,而缓冲流正如其名称一样,它能够提供一个缓冲,提前将部分内容存入内存(缓冲区)在下次读取时,如果缓冲区中存在此数据,则无需再去请求外部设备。同理,当向外部设备写入数据时,也是由缓冲区处理,而不是直接向外部设备写入。 - -![image-20221004125755217](https://s2.loli.net/2022/10/04/S8O61JP2lqKTzjd.png) - -要创建一个缓冲字节流,只需要将原本的流作为构造参数传入BufferedInputStream即可: - -```java -public static void main(String[] args) { - try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("test.txt"))){ //传入FileInputStream - System.out.println((char) bufferedInputStream.read()); //操作和原来的流是一样的 - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -实际上进行I/O操作的并不是BufferedInputStream,而是我们传入的FileInputStream,而BufferedInputStream虽然有着同样的方法,但是进行了一些额外的处理然后再调用FileInputStream的同名方法,这样的写法称为`装饰者模式`,我们会在设计模式篇中详细介绍。我们可以来观察一下它的`close`方法源码: - -```java -public void close() throws IOException { - byte[] buffer; - while ( (buffer = buf) != null) { - if (bufUpdater.compareAndSet(this, buffer, null)) { //CAS无锁算法,并发会用到,暂时不需要了解 - InputStream input = in; - in = null; - if (input != null) - input.close(); - return; - } - // Else retry in case a new buf was CASed in fill() - } -} -``` - -实际上这种模式是父类FilterInputStream提供的规范,后面我们还会讲到更多FilterInputStream的子类。 - -我们可以发现在BufferedInputStream中还存在一个专门用于缓存的数组: - -```java -/** - * The internal buffer array where the data is stored. When necessary, - * it may be replaced by another array of - * a different size. - */ -protected volatile byte buf[]; -``` - -I/O操作一般不能重复读取内容(比如键盘发送的信号,主机接收了就没了),而缓冲流提供了缓冲机制,一部分内容可以被暂时保存,BufferedInputStream支持`reset()`和`mark()`操作,首先我们来看看`mark()`方法的介绍: - -```java -/** - * Marks the current position in this input stream. A subsequent - * call to the reset method repositions this stream at - * the last marked position so that subsequent reads re-read the same bytes. - *

- * The readlimit argument tells this input stream to - * allow that many bytes to be read before the mark position gets - * invalidated. - *

- * This method simply performs in.mark(readlimit). - * - * @param readlimit the maximum limit of bytes that can be read before - * the mark position becomes invalid. - * @see java.io.FilterInputStream#in - * @see java.io.FilterInputStream#reset() - */ -public synchronized void mark(int readlimit) { - in.mark(readlimit); -} -``` - -当调用`mark()`之后,输入流会以某种方式保留之后读取的`readlimit`数量的内容,当读取的内容数量超过`readlimit`则之后的内容不会被保留,当调用`reset()`之后,会使得当前的读取位置回到`mark()`调用时的位置。 - -```java -public static void main(String[] args) { - try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("test.txt"))){ - bufferedInputStream.mark(1); //只保留之后的1个字符 - System.out.println((char) bufferedInputStream.read()); - System.out.println((char) bufferedInputStream.read()); - bufferedInputStream.reset(); //回到mark时的位置 - System.out.println((char) bufferedInputStream.read()); - System.out.println((char) bufferedInputStream.read()); - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -我们发现虽然后面的部分没有保存,但是依然能够正常读取,其实`mark()`后保存的读取内容是取`readlimit`和BufferedInputStream类的缓冲区大小两者中的最大值,而并非完全由`readlimit`确定。因此我们限制一下缓冲区大小,再来观察一下结果: - -```java -public static void main(String[] args) { - try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("test.txt"), 1)){ //将缓冲区大小设置为1 - bufferedInputStream.mark(1); //只保留之后的1个字符 - System.out.println((char) bufferedInputStream.read()); - System.out.println((char) bufferedInputStream.read()); //已经超过了readlimit,继续读取会导致mark失效 - bufferedInputStream.reset(); //mark已经失效,无法reset() - System.out.println((char) bufferedInputStream.read()); - System.out.println((char) bufferedInputStream.read()); - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -了解完了BufferedInputStream之后,我们再来看看BufferedOutputStream,其实和BufferedInputStream原理差不多,只是反向操作: - -```java -public static void main(String[] args) { - try (BufferedOutputStream outputStream = new BufferedOutputStream(new FileOutputStream("output.txt"))){ - outputStream.write("lbwnb".getBytes()); - outputStream.flush(); - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -操作和FileOutputStream一致,这里就不多做介绍了。 - -既然有缓冲字节流,那么肯定也有缓冲字符流,缓冲字符流和缓冲字节流一样,也有一个专门的缓冲区,BufferedReader构造时需要传入一个Reader对象: - -```java -public static void main(String[] args) { - try (BufferedReader reader = new BufferedReader(new FileReader("test.txt"))){ - System.out.println((char) reader.read()); - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -使用和reader也是一样的,内部也包含一个缓存数组: - -```java -private char cb[]; -``` - -相比Reader更方便的是,它支持按行读取: - -```java -public static void main(String[] args) { - try (BufferedReader reader = new BufferedReader(new FileReader("test.txt"))){ - System.out.println(reader.readLine()); //按行读取 - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -读取后直接得到一个字符串,当然,它还能把每一行内容依次转换为集合类提到的Stream流: - -```java -public static void main(String[] args) { - try (BufferedReader reader = new BufferedReader(new FileReader("test.txt"))){ - reader - .lines() - .limit(2) - .distinct() - .sorted() - .forEach(System.out::println); - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -它同样也支持`mark()`和`reset()`操作: - -```java -public static void main(String[] args) { - try (BufferedReader reader = new BufferedReader(new FileReader("test.txt"))){ - reader.mark(1); - System.out.println((char) reader.read()); - reader.reset(); - System.out.println((char) reader.read()); - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -BufferedReader处理纯文本文件时就更加方便了,BufferedWriter在处理时也同样方便: - -```java -public static void main(String[] args) { - try (BufferedWriter reader = new BufferedWriter(new FileWriter("output.txt"))){ - reader.newLine(); //使用newLine进行换行 - reader.write("汉堡做滴彳亍不彳亍"); //可以直接写入一个字符串 - reader.flush(); //清空缓冲区 - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -合理使用缓冲流,可以大大提高我们程序的运行效率,只不过现在初学阶段,很少会有机会接触到实际的应用场景。 - -### 转换流 - -有时会遇到这样一个很麻烦的问题:我这里读取的是一个字符串或是一个个字符,但是我只能往一个OutputStream里输出,但是OutputStream又只支持byte类型,如果要往里面写入内容,进行数据转换就会很麻烦,那么能否有更加简便的方式来做这样的事情呢? - -```java -public static void main(String[] args) { - try(OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream("test.txt"))){ //虽然给定的是FileOutputStream,但是现在支持以Writer的方式进行写入 - writer.write("lbwnb"); //以操作Writer的样子写入OutputStream - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -同样的,我们现在只拿到了一个InputStream,但是我们希望能够按字符的方式读取,我们就可以使用InputStreamReader来帮助我们实现: - -```java -public static void main(String[] args) { - try(InputStreamReader reader = new InputStreamReader(new FileInputStream("test.txt"))){ //虽然给定的是FileInputStream,但是现在支持以Reader的方式进行读取 - System.out.println((char) reader.read()); - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -InputStreamReader和OutputStreamWriter本质也是Reader和Writer,因此可以直接放入BufferedReader来实现更加方便的操作。 - -### 打印流 - -打印流其实我们从一开始就在使用了,比如`System.out`就是一个PrintStream,PrintStream也继承自FilterOutputStream类因此依然是装饰我们传入的输出流,但是它存在自动刷新机制,例如当向PrintStream流中写入一个字节数组后自动调用`flush()`方法。PrintStream也永远不会抛出异常,而是使用内部检查机制`checkError()`方法进行错误检查。最方便的是,它能够格式化任意的类型,将它们以字符串的形式写入到输出流。 - -```java -public final static PrintStream out = null; -``` - -可以看到`System.out`也是PrintStream,不过默认是向控制台打印,我们也可以让它向文件中打印: - -```java -public static void main(String[] args) { - try(PrintStream stream = new PrintStream(new FileOutputStream("test.txt"))){ - stream.println("lbwnb"); //其实System.out就是一个PrintStream - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -我们平时使用的`println`方法就是PrintStream中的方法,它会直接打印基本数据类型或是调用对象的`toString()`方法得到一个字符串,并将字符串转换为字符,放入缓冲区再经过转换流输出到给定的输出流上。 - -![img](https://s2.loli.net/2022/10/04/w8RKJxLm6Ik5usn.png) - -因此实际上内部还包含这两个内容: - -```java -/** - * Track both the text- and character-output streams, so that their buffers - * can be flushed without flushing the entire stream. - */ -private BufferedWriter textOut; -private OutputStreamWriter charOut; -``` - -与此相同的还有一个PrintWriter,不过他们的功能基本一致,PrintWriter的构造方法可以接受一个Writer作为参数,这里就不再做过多阐述了。 - -而我们之前使用的Scanner,使用的是系统提供的输入流: - -```java -public static void main(String[] args) { - Scanner scanner = new Scanner(System.in); //系统输入流,默认是接收控制台输入 -} -``` - -我们也可以使用Scanner来扫描其他的输入流: - -```java -public static void main(String[] args) throws FileNotFoundException { - Scanner scanner = new Scanner(new FileInputStream("秘制小汉堡.txt")); //将文件内容作为输入流进行扫描 -} -``` - -相当于直接扫描文件中编写的内容,同样可以读取。 - -### 数据流 - -数据流DataInputStream也是FilterInputStream的子类,同样采用装饰者模式,最大的不同是它支持基本数据类型的直接读取: - -```java -public static void main(String[] args) { - try (DataInputStream dataInputStream = new DataInputStream(new FileInputStream("test.txt"))){ - System.out.println(dataInputStream.readBoolean()); //直接将数据读取为任意基本数据类型 - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -用于写入基本数据类型: - -```java -public static void main(String[] args) { - try (DataOutputStream dataOutputStream = new DataOutputStream(new FileOutputStream("output.txt"))){ - dataOutputStream.writeBoolean(false); - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -注意,写入的是二进制数据,并不是写入的字符串,使用DataInputStream可以读取,一般他们是配合一起使用的。 - -### 对象流 - -既然基本数据类型能够读取和写入基本数据类型,那么能否将对象也支持呢?ObjectOutputStream不仅支持基本数据类型,通过对对象的序列化操作,以某种格式保存对象,来支持对象类型的IO,注意:它不是继承自FilterInputStream的。 - -```java -public static void main(String[] args) { - try (ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("output.txt")); - ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("output.txt"))){ - People people = new People("lbw"); - outputStream.writeObject(people); - outputStream.flush(); - people = (People) inputStream.readObject(); - System.out.println(people.name); - }catch (IOException | ClassNotFoundException e) { - e.printStackTrace(); - } -} - -static class People implements Serializable{ //必须实现Serializable接口才能被序列化 - String name; - - public People(String name){ - this.name = name; - } -} -``` - -在我们后续的操作中,有可能会使得这个类的一些结构发生变化,而原来保存的数据只适用于之前版本的这个类,因此我们需要一种方法来区分类的不同版本: - -```java -static class People implements Serializable{ - private static final long serialVersionUID = 123456; //在序列化时,会被自动添加这个属性,它代表当前类的版本,我们也可以手动指定版本。 - - String name; - - public People(String name){ - this.name = name; - } -} -``` - -当发生版本不匹配时,会无法反序列化为对象: - -```java -java.io.InvalidClassException: com.test.Main$People; local class incompatible: stream classdesc serialVersionUID = 123456, local class serialVersionUID = 1234567 - at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:699) - at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:2003) - at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1850) - at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2160) - at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1667) - at java.io.ObjectInputStream.readObject(ObjectInputStream.java:503) - at java.io.ObjectInputStream.readObject(ObjectInputStream.java:461) - at com.test.Main.main(Main.java:27) -``` - -如果我们不希望某些属性参与到序列化中进行保存,我们可以添加`transient`关键字: - -```java -public static void main(String[] args) { - try (ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("output.txt")); - ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("output.txt"))){ - People people = new People("lbw"); - outputStream.writeObject(people); - outputStream.flush(); - people = (People) inputStream.readObject(); - System.out.println(people.name); //虽然能得到对象,但是name属性并没有保存,因此为null - }catch (IOException | ClassNotFoundException e) { - e.printStackTrace(); - } -} - -static class People implements Serializable{ - private static final long serialVersionUID = 1234567; - - transient String name; - - public People(String name){ - this.name = name; - } -} -``` - -其实我们可以看到,在一些JDK内部的源码中,也存在大量的transient关键字,使得某些属性不参与序列化,取消这些不必要保存的属性,可以节省数据空间占用以及减少序列化时间。 - -*** - -## 实战:图书管理系统 - -要求实现一个图书管理系统(控制台),支持以下功能:保存书籍信息(要求持久化),查询、添加、删除、修改书籍信息。 \ No newline at end of file diff --git a/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(四)重制版.md b/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(四)重制版.md deleted file mode 100644 index 449dc9a..0000000 --- a/青空笔记/JavaSE 笔记 2023重制版/JavaSE笔记(四)重制版.md +++ /dev/null @@ -1,1864 +0,0 @@ -![image-20220922170926093](https://s2.loli.net/2022/09/22/lmKBNFc5wPEgjaS.png) - -# 面向对象高级篇 - -经过前面的学习,我们已经了解了面向对象编程的大部分基础内容,这一部分,我们将继续探索面向对象编程过程中一些常用的东西。 - -## 基本类型包装类 - -Java并不是纯面向对象的语言,虽然Java语言是一个面向对象的语言,但是Java中的基本数据类型却不是面向对象的。Java中的基本类型,如果想通过对象的形式去使用他们,Java提供的基本类型包装类,使得Java能够更好的体现面向对象的思想,同时也使得基本类型能够支持对象操作! - -### 包装类介绍 - -所有的包装类层次结构如下: - -![5c3a6a27-6370-4c60-9bbc-8039e11e752d](https://s2.loli.net/2022/09/22/mulb5VdvBLiWNe2.png) - -其中能够表示数字的基本类型包装类,继承自Number类,对应关系如下表: - -- byte -> Byte -- boolean -> Boolean -- short -> Short -- char -> Character -- int -> Integer -- long -> Long -- float -> Float -- double -> Double - -我们可以直接使用,这里我们以Integer类为例: - -```java -public static void main(String[] args) { - Integer i = new Integer(10); //将10包装为一个Integer类型的变量 -} -``` - -包装类实际上就是将我们的基本数据类型,封装成一个类(运用了封装的思想)我们可以来看看Integer类中是怎么写的: - -```java -private final int value; //类中实际上就靠这个变量在存储包装的值 - -public Integer(int value) { - this.value = value; -} -``` - -包装类型支持自动装箱,我们可以直接将一个对应的基本类型值作为对应包装类型引用变量的值: - -```java -public static void main(String[] args) { - Integer i = 10; //将int类型值作为包装类型使用 -} -``` - -这是怎么做到的?为什么一个对象类型的值可以直接接收一个基本类类型的值?实际上这里就是自动装箱: - -```java -public static void main(String[] args) { - Integer i = Integer.valueOf(10); //上面的写法跟这里是等价的 -} -``` - -这里本质上就是被自动包装成了一个Integer类型的对象,只是语法上为了简单,就支持像这样编写。既然能装箱,也是支持拆箱的: - -```java -public static void main(String[] args) { - Integer i = 10; - int a = i; -} -``` - -实际上上面的写法本质上就是: - -```java -public static void main(String[] args) { - Integer i = 10; - int a = i.intValue(); //通过此方法变成基本类型int值 -} -``` - -这里就是自动拆箱,得益于包装类型的自动装箱和拆箱机制,我们可以让包装类型轻松地参与到基本类型的运算中: - -```java -public static void main(String[] args) { - Integer a = 10, b = 20; - int c = a * b; //直接自动拆箱成基本类型参与到计算中 - System.out.println(c); -} -``` - -因为包装类是一个类,不是基本类型,所以说两个不同的对象,那么是不相等的: - -```java -public static void main(String[] args) { - Integer a = new Integer(10); - Integer b = new Integer(10); - - System.out.println(a == b); //虽然a和b的值相同,但是并不是同一个对象,所以说==判断为假 -} -``` - -那么自动装箱的呢? - -```java -public static void main(String[] args) { - Integer a = 10, b = 10; - System.out.println(a == b); -} -``` - -我们发现,通过自动装箱转换的Integer对象,如果值相同,得到的会是同一个对象,这是因为: - -```java -public static Integer valueOf(int i) { - if (i >= IntegerCache.low && i <= IntegerCache.high) //这里会有一个IntegerCache,如果在范围内,那么会直接返回已经提前创建好的对象 - return IntegerCache.cache[i + (-IntegerCache.low)]; - return new Integer(i); -} -``` - -IntegerCache会默认缓存-128~127之间的所有值,将这些值提前做成包装类放在数组中存放,虽然我们目前还没有学习数组,但是各位小伙伴只需要知道,我们如果直接让 -128~127之间的值自动装箱为Integer类型的对象,那么始终都会得到同一个对象,这是为了提升效率,因为小的数使用频率非常高,有些时候并不需要创建那么多对象,创建对象越多,内存也会消耗更多。 - -但是如果超出这个缓存范围的话,就会得到不同的对象了: - -```java -public static void main(String[] args) { - Integer a = 128, b = 128; - System.out.println(a == b); -} -``` - -这样就不会得到同一个对象了,因为超出了缓存的范围。同样的,Long、Short、Byte类型的包装类也有类似的机制,感兴趣的小伙伴可以自己点进去看看。 - -我们来看看包装类中提供了哪些其他的方法,包装类支持字符串直接转换: - -```java -public static void main(String[] args) { - Integer i = new Integer("666"); //直接将字符串的666,转换为数字666 - System.out.println(i); -} -``` - -当然,字符串转Integer有多个方法: - -```java -public static void main(String[] args) { - Integer i = Integer.valueOf("5555"); - //Integer i = Integer.parseInt("5555"); - System.out.println(i); -} -``` - -我们甚至可以对十六进制和八进制的字符串进行解码,得到对应的int值: - -```java -public static void main(String[] args) { - Integer i = Integer.decode("0xA6"); - System.out.println(i); -} -``` - -也可以将十进制的整数转换为其他进制的字符串: - -```java -public static void main(String[] args) { - System.out.println(Integer.toHexString(166)); -} -``` - -当然,Integer中提供的方法还有很多,这里就不一一列出了。 - -### 特殊包装类 - -除了我们上面认识的这几种基本类型包装类之外,还有两个比较特殊的包装类型。 - -其中第一个是用于计算超大数字的BigInteger,我们知道,即使是最大的long类型,也只能表示64bit的数据,无法表示一个非常大的数,但是BigInteger没有这些限制,我们可以让他等于一个非常大的数字: - -```java -public static void main(String[] args) { - BigInteger i = BigInteger.valueOf(Long.MAX_VALUE); //表示Long的最大值,轻轻松松 - System.out.println(i); -} -``` - -我们可以通过调用类中的方法,进行运算操作: - -```java -public static void main(String[] args) { - BigInteger i = BigInteger.valueOf(Long.MAX_VALUE); - i = i.multiply(BigInteger.valueOf(Long.MAX_VALUE)); //即使是long的最大值乘以long的最大值,也能给你算出来 - System.out.println(i); -} -``` - -我们来看看结果: - -![image-20220922211414392](https://s2.loli.net/2022/09/22/FTPGhgnAEm1QKkV.png) - -可以看到,此时数值已经非常大了,也可以轻松计算出来。咱们来点更刺激的: - -```java -public static void main(String[] args) { - BigInteger i = BigInteger.valueOf(Long.MAX_VALUE); - i = i.pow(100); //long的最大值来个100次方吧 - System.out.println(i); -} -``` - -可以看到,这个数字已经大到一排显示不下了: - -![image-20220922211651719](https://s2.loli.net/2022/09/22/w1OoFmbLiJ4rlcV.png) - -一般情况,对于非常大的整数计算,我们就可以使用BigInteger来完成。 - -我们接着来看第二种,前面我们说了,浮点类型精度有限,对于需要精确计算的场景,就没办法了,而BigDecimal可以实现小数的精确计算。 - -```java -public static void main(String[] args) { - BigDecimal i = BigDecimal.valueOf(10); - i = i.divide(BigDecimal.valueOf(3), 100, RoundingMode.CEILING); - //计算10/3的结果,精确到小数点后100位 - //RoundingMode是舍入模式,就是精确到最后一位时,该怎么处理,这里CEILING表示向上取整 - System.out.println(i); -} -``` - -可以看到,确实可以精确到这种程度: - -![image-20220922212222762](https://s2.loli.net/2022/09/22/IUJ5rwzxonCBMT4.png) - -但是注意,对于这种结果没有终点的,无限循环的小数,我们必须要限制长度,否则会出现异常。 - -*** - -## 数组 - -我们接着来看一个比较特殊的类型,数组。 - -假设出现一种情况,我们想记录100个数字,要是采用定义100个变量的方式可以吗?是不是有点太累了?这种情况我们就可以使用数组来存放一组相同类型的数据。 - -![image-20220922214604430](https://s2.loli.net/2022/09/22/y4ISWZLrYE3Pdig.png) - -### 一维数组 - -数组是相同类型数据的有序集合,数组可以代表任何相同类型的一组内容(包括引用类型和基本类型)其中存放的每一个数据称为数组的一个元素,我们来看看如何去定义一个数组变量: - -```java -public static void main(String[] args) { - int[] array; //类型[]就表示这个是一个数组类型 -} -``` - -注意,数组类型比较特殊,它本身也是类,但是编程不可见(底层C++写的,在运行时动态创建)即使是基本类型的数组,也是以对象的形式存在的,并不是基本数据类型。所以,我们要创建一个数组,同样需要使用`new `关键字: - -```java -public static void main(String[] args) { - int[] array = new int[10]; //在创建数组时,需要指定数组长度,也就是可以容纳多个int变量的值 - Object obj = array; //因为同样是类,肯定是继承自Object的,所以说可以直接向上转型 -} -``` - -除了上面这种方式之外,我们也可以使用其他方式: - -```java -类型[] 变量名称 = new 类型[数组大小]; -类型 变量名称[] = new 类型[数组大小]; //支持C语言样式,但不推荐! - -类型[] 变量名称 = new 类型[]{...}; //静态初始化(直接指定值和大小) -类型[] 变量名称 = {...}; //同上,但是只能在定义时赋值 -``` - -创建出来的数组每个位置上都有默认值,如果是引用类型,就是null,如果是基本数据类型,就是0,或者是false,跟对象成员变量的默认值是一样的,要访问数组的某一个元素,我们可以: - -```java -public static void main(String[] args) { - int[] array = new int[10]; - System.out.println("数组的第一个元素为:"+array[0]); //使用 变量名[下标] 的方式访问 -} -``` - -注意,数组的下标是从0开始的,不是从1开始的,所以说第一个元素的下标就是0,我们要访问第一个元素,那么直接输入0就行了,但是注意千万别写成负数或是超出范围了,否则会出现异常。 - -我们也可以使用这种方式为数组的元素赋值: - -```java -public static void main(String[] args) { - int[] array = new int[10]; - array[0] = 888; //就像使用变量一样,是可以放在赋值运算符左边的,我们可以直接给对应下标位置的元素赋值 - System.out.println("数组的第一个元素为:"+array[0]); -} -``` - -因为数组本身也是一个对象,数组对象也是具有属性的,比如长度: - -```java -public static void main(String[] args) { - int[] array = new int[10]; - System.out.println("当前数组长度为:"+array.length); //length属性是int类型的值,表示当前数组长度,长度是在一开始创建数组的时候就确定好的 -} -``` - -注意,这个`length`是在一开始就确定的,而且是`final`类型的,不允许进行修改,也就是说数组的长度一旦确定,不能随便进行修改,如果需要使用更大的数组,只能重新创建。 - -当然,既然是类型,那么肯定也是继承自Object类的: - -```java -public static void main(String[] args) { - int[] array = new int[10]; - System.out.println(array.toString()); - System.out.println(array.equals(array)); -} -``` - -但是,很遗憾,除了clone()之外,这些方法并没有被重写,也就是说依然是采用的Object中的默认实现: - -![image-20220922220403391](https://s2.loli.net/2022/09/22/UfTGu9sZheW21jB.png) - -所以说通过`toString()`打印出来的结果,好丑,只不过我们可以发现,数组类型的类名很奇怪,是`[`开头的。 - -因此,如果我们要打印整个数组中所有的元素,得一个一个访问: - -```java -public static void main(String[] args) { - int[] array = new int[10]; - for (int i = 0; i < array.length; i++) { - System.out.print(array[i] + " "); - } -} -``` - -有时候为了方便,我们可以使用简化版的for语句`foreach`语法来遍历数组中的每一个元素: - -```java -public static void main(String[] args) { - int[] array = new int[10]; - for (int i : array) { //int i就是每一个数组中的元素,array就是我们要遍历的数组 - System.out.print(i+" "); //每一轮循环,i都会更新成数组中下一个元素 - } -} -``` - -是不是感觉这种写法更加简洁?只不过这仅仅是语法糖而已,编译之后依然是跟上面一样老老实实在遍历的: - -```java -public static void main(String[] args) { //反编译的结果 - int[] array = new int[10]; - int[] var2 = array; - int var3 = array.length; - - for(int var4 = 0; var4 < var3; ++var4) { - int i = var2[var4]; - System.out.print(i + " "); - } - -} -``` - -对于这种普通的数组,其实使用还是挺简单的。这里需要特别说一下,对于基本类型的数组来说,是不支持自动装箱和拆箱的: - -```java -public static void main(String[] args) { - int[] arr = new int[10]; - Integer[] test = arr; -} -``` - -还有,由于基本数据类型和引用类型不同,所以说int类型的数组时不能被Object类型的数组变量接收的: - -![image-20220924114859252](https://s2.loli.net/2022/09/24/XbfZ9YHkqjv7613.png) - -但是如果是引用类型的话,是可以的: - -```java -public static void main(String[] args) { - String[] arr = new String[10]; - Object[] array = arr; //数组同样支持向上转型 -} -``` - -```java -public static void main(String[] args) { - Object[] arr = new Object[10]; - String[] array = (String[]) arr; //也支持向下转型 -} -``` - -### 多维数组 - -前面我们介绍了简单的数组(一维数组)既然数组可以是任何类型的,那么我们能否创建数组类型的数组呢?答案是可以的,套娃嘛,谁不会: - -```java -public static void main(String[] args) { - int[][] array = new int[2][10]; //数组类型数组那么就要写两个[]了 -} -``` - -存放数组的数组,相当于将维度进行了提升,比如上面的就是一个2x10的数组: - -![image-20220922221557130](https://s2.loli.net/2022/09/22/kRcO1aGY6fMBiu9.png) - -这个中数组一共有2个元素,每个元素都是一个存放10个元素的数组,所以说最后看起来就像一个矩阵一样。甚至可以继续套娃,将其变成一个三维数组,也就是存放数组的数组的数组。 - -```java -public static void main(String[] args) { - int[][] arr = { {1, 2}, - {3, 4}, - {5, 6}}; //一个三行两列的数组 - System.out.println(arr[2][1]); //访问第三行第二列的元素 -} -``` - -在访问多维数组时,我们需要使用多次`[]`运算符来得到对应位置的元素。如果我们要遍历多维数组话,那么就需要多次嵌套循环: - -```java -public static void main(String[] args) { - int[][] arr = new int[][]{{1, 2}, - {3, 4}, - {5, 6}}; - for (int i = 0; i < 3; i++) { //要遍历一个二维数组,那么我们得一列一列一行一行地来 - for (int j = 0; j < 2; j++) { - System.out.println(arr[i][j]); - } - } -} -``` - -### 可变长参数 - -我们接着来看数组的延伸应用,实际上我们的方法是支持可变长参数的,什么是可变长参数? - -```java -public class Person { - String name; - int age; - String sex; - - public void test(String... strings){ - - } -} -``` - -我们在使用时,可以传入0 - N个对应类型的实参: - -```java -public static void main(String[] args) { - Person person = new Person(); - person.test("1!", "5!", "哥们在这跟你说唱"); //这里我们可以自由传入任意数量的字符串 -} -``` - -那么我们在方法中怎么才能得到这些传入的参数呢,实际上可变长参数本质就是一个数组: - -```java -public void test(String... strings){ //strings这个变量就是一个String[]类型的 - for (String string : strings) { - System.out.println(string); //遍历打印数组中每一个元素 - } -} -``` - -注意,如果同时存在其他参数,那么可变长参数只能放在最后: - -```java -public void test(int a, int b, String... strings){ - -} -``` - -这里最后我们再来说一个从开始到现在一直都没有说的东西: - -```java -public static void main(String[] args) { //这个String[] args到底是个啥??? - -} -``` - -实际上这个是我们在执行Java程序时,输入的命令行参数,我们可以来打印一下: - -```java -public static void main(String[] args) { - for (String arg : args) { - System.out.println(arg); - } -} -``` - -可以看到,默认情况下直接运行什么都没有,但是如果我们在运行时,添加点内容的话: - -```sh -java com/test/Main lbwnb aaaa xxxxx #放在包中需要携带主类完整路径才能运行 -``` - -可以看到,我们在后面随意添加的三个参数,都放到数组中了: - -![image-20220922223152648](https://s2.loli.net/2022/09/22/DL3WTMdRwrSYJIl.png) - -这个东西我们作为新手一般也不会用到,只做了解就行了。 - -*** - -## 字符串 - -字符串类是一个比较特殊的类,它用于保存字符串。我们知道,基本类型`char`可以保存一个2字节的Unicode字符,而字符串则是一系列字符的序列(在C中就是一个字符数组)Java中没有字符串这种基本类型,因此只能使用类来进行定义。注意,字符串中的字符一旦确定,无法进行修改,只能重新创建。 - -### String类 - -String本身也是一个类,只不过它比较特殊,每个用双引号括起来的字符串,都是String类型的一个实例对象: - -```java -public static void main(String[] args) { - String str = "Hello World!"; -} -``` - -我们也可以象征性地使用一下new关键字: - -```java -public static void main(String[] args) { - String str = new String("Hello World!"); //这种方式就是创建一个新的对象 -} -``` - -注意,如果是直接使用双引号创建的字符串,如果内容相同,为了优化效率,那么始终都是同一个对象: - -```java -public static void main(String[] args) { - String str1 = "Hello World"; - String str2 = "Hello World"; - System.out.println(str1 == str2); -} -``` - -但是如果我们使用构造方法主动创建两个新的对象,那么就是不同的对象了: - -```java -public static void main(String[] args) { - String str1 = new String("Hello World"); - String str2 = new String("Hello World"); - System.out.println(str1 == str2); -} -``` - -至于为什么会出现这种情况,我们在JVM篇视频教程中会进行详细的介绍,这里各位小伙伴只需要记住就行了。因此,如果我们仅仅是想要判断两个字符串的内容是否相同,不要使用`==`,String类重载了`equals`方法用于判断和比较内容是否相同: - -```java -public static void main(String[] args) { - String str1 = new String("Hello World"); - String str2 = new String("Hello World"); - System.out.println(str1.equals(str2)); //字符串的内容比较,一定要用equals -} -``` - -既然String也是一个类,那么肯定是具有一些方法的,我们可以来看看: - -```java -public static void main(String[] args) { - String str = "Hello World"; - System.out.println(str.length()); //length方法可以求字符串长度,这个长度是字符的数量 -} -``` - -因为双引号括起来的字符串本身就是一个实例对象,所以说我们也可以直接用: - -```java -public static void main(String[] args) { - System.out.println("Hello World".length()); //虽然看起来挺奇怪的,但是确实支持这种写法 -} -``` - -字符串类中提供了很多方便我们操作的方法,比如字符串的裁剪、分割操作: - -```java -public static void main(String[] args) { - String str = "Hello World"; - String sub = str.substring(0, 3); //分割字符串,并返回一个新的子串对象 - System.out.println(sub); -} -``` - -```java -public static void main(String[] args) { - String str = "Hello World"; - String[] strings = str.split(" "); //使用split方法进行字符串分割,比如这里就是通过空格分隔,得到一个字符串数组 - for (String string : strings) { - System.out.println(string); - } -} -``` - -字符数组和字符串之间是可以快速进行相互转换的: - -```java -public static void main(String[] args) { - String str = "Hello World"; - char[] chars = str.toCharArray(); - System.out.println(chars); -} -``` - -```java -public static void main(String[] args) { - char[] chars = new char[]{'奥', '利', '给'}; - String str = new String(chars); - System.out.println(str); -} -``` - -当然,String类还有很多其他的一些方法,这里就不一一介绍了。 - -### StringBuilder类 - -我们在之前的学习中已经了解,字符串支持使用`+`和`+=`进行拼接操作。 - -但是拼接字符串实际上底层需要进行很多操作,如果程序中大量进行字符串的拼接似乎不太好,编译器是很聪明的,String的拼接会在编译时进行各种优化: - -```java -public static void main(String[] args) { - String str = "杰哥" + "你干嘛"; //我们在写代码时使用的是拼接的形式 - System.out.println(str); -} -``` - -编译之后就变成这样了: - -```java -public static void main(String[] args) { - String str = "杰哥你干嘛"; - System.out.println(str); -} -``` - -对于变量来说,也有优化,比如下面这种情况: - -```java -public static void main(String[] args) { - String str1 = "你看"; - String str2 = "这"; - String str3 = "汉堡"; - String str4 = "做滴"; - String str5 = "行不行"; - String result = str1 + str2 + str3 + str4 + str5; //5个变量连续加 - System.out.println(result); -} -``` - -如果直接使用加的话,每次运算都会生成一个新的对象,这里进行4次加法运算,那么中间就需要产生4个字符串对象出来,是不是有点太浪费了?这种情况实际上会被优化为下面的写法: - -```java -public static void main(String[] args) { - String str1 = "你看"; - String str2 = "这"; - String str3 = "汉堡"; - String str4 = "做滴"; - String str5 = "行不行"; - StringBuilder builder = new StringBuilder(); - builder.append(str1).append(str2).append(str3).append(str4).append(str5); - System.out.println(builder.toString()); -} -``` - -这里创建了一个StringBuilder的类型,这个类型是干嘛的呢?实际上它就是专门用于构造字符串的,我们可以使用它来对字符串进行拼接、裁剪等操作,它就像一个字符串编辑器,弥补了字符串不能修改的不足: - -```java -public static void main(String[] args) { - StringBuilder builder = new StringBuilder(); //一开始创建时,内部什么都没有 - builder.append("AAA"); //我们可以使用append方法来讲字符串拼接到后面 - builder.append("BBB"); - System.out.println(builder.toString()); //当我们字符串编辑完成之后,就可以使用toString转换为字符串了 -} -``` - -它还支持裁剪等操作: - -```java -public static void main(String[] args) { - StringBuilder builder = new StringBuilder("AAABBB"); //在构造时也可以指定初始字符串 - builder.delete(2, 4); //删除2到4这个范围内的字符 - System.out.println(builder.toString()); -} -``` - -当然,StringBuilder类的编辑操作也非常多,这里就不一一列出了。 - -### 正则表达式 - -我们现在想要实现这样一个功能,对于给定的字符串进行判断,如果字符串符合我们的规则,那么就返回真,否则返回假,比如现在我们想要判断字符串是不是邮箱的格式: - -```java -public static void main(String[] args) { - String str = "aaaa731341@163.com"; - //假设邮箱格式为 数字/字母@数字/字母.com -} -``` - -那么现在请你设计一个Java程序用于判断,你该怎么做?是不是感觉很麻烦,但是我们使用正则表达式就可以很轻松解决这种字符串格式匹配问题。 - -> 正则表达式(regular expression)描述了一种字符串匹配的模式(pattern),可以用来检查一个串是否含有某种子串、将匹配的子串替换或者从某个串中取出符合某个条件的子串等。 - -我们先来看看下面的这个例子: - -```java -public static void main(String[] args) { - String str = "oooo"; - //matches方法用于对给定正则表达式进行匹配,匹配成功返回true,否则返回false - System.out.println(str.matches("o+")); //+表示对前面这个字符匹配一次或多次,这里字符串是oooo,正好可以匹配 -} -``` - -用于规定给定组件必须要出现多少次才能满足匹配的,我们一般称为限定符,限定符表如下: - -| 字符 | 描述 | -| :---: | :----------------------------------------------------------: | -| * | 匹配前面的子表达式零次或多次。例如,**zo\*** 能匹配 **"z"** 以及 **"zoo"**。***** 等价于 **{0,}**。 | -| + | 匹配前面的子表达式一次或多次。例如,**zo+** 能匹配 **"zo"** 以及 "**zoo"**,但不能匹配 **"z"**。**+** 等价于 **{1,}**。 | -| ? | 匹配前面的子表达式零次或一次。例如,**do(es)?** 可以匹配 **"do"** 、 **"does"**、 **"doxy"** 中的 **"do"** 。**?** 等价于 **{0,1}**。 | -| {n} | n 是一个非负整数。匹配确定的 **n** 次。例如,**o{2}** 不能匹配 **"Bob"** 中的 **o**,但是能匹配 **"food"** 中的两个 **o**。 | -| {n,} | n 是一个非负整数。至少匹配n 次。例如,**o{2,}** 不能匹配 **"Bob"** 中的 **o**,但能匹配 **"foooood"** 中的所有 **o**。**o{1,}** 等价于 **o+**。**o{0,}** 则等价于 **o\***。 | -| {n,m} | m 和 n 均为非负整数,其中 n <= m。最少匹配 n 次且最多匹配 m 次。例如,**o{1,3}** 将匹配 **"fooooood"** 中的前三个 **o**。**o{0,1}** 等价于 **o?**。请注意在逗号和两个数之间不能有空格。 | - -如果我们想要表示一个范围内的字符,可以使用方括号: - -```java -public static void main(String[] args) { - String str = "abcabccaa"; - System.out.println(str.matches("[abc]*")); //表示abc这几个字符可以出现 0 - N 次 -} -``` - -对于普通字符来说,我们可以下面的方式实现多种字符匹配: - -| 字符 | 描述 | -| :--------: | :----------------------------------------------------------: | -| **[ABC]** | 匹配 **[...]** 中的所有字符,例如 **[aeiou]** 匹配字符串 "google runoob taobao" 中所有的 e o u a 字母。 | -| **[^ABC]** | 匹配除了 **[...]** 中字符的所有字符,例如 **[^aeiou]** 匹配字符串 "google runoob taobao" 中除了 e o u a 字母的所有字母。 | -| **[A-Z]** | [A-Z] 表示一个区间,匹配所有大写字母,[a-z] 表示所有小写字母。 | -| **.** | 匹配除换行符(\n、\r)之外的任何单个字符,相等于 \[^\n\r] | -| **[\s\S]** | 匹配所有。\s 是匹配所有空白符,包括换行,\S 非空白符,不包括换行。 | -| **\w** | 匹配字母、数字、下划线。等价于 [A-Za-z0-9_] | - -当然,这里仅仅是对正则表达式的简单使用,实际上正则表达式内容非常多,如果需要完整学习正则表达式,可以到:https://www.runoob.com/regexp/regexp-syntax.html - -正则表达式并不是只有Java才支持,其他很多语言比如JavaScript、Python等等都是支持正则表达式的。 - -*** - -## 内部类 - -上一章我们详细介绍了类,我们现在已经知道该如何创建类、使用类了。当然,类的创建其实可以有多种多样的方式,并不仅仅局限于普通的创建。内部类顾名思义,就是创建在内部的类,那么具体是什么的内部呢,我们接着就来讨论一下。 - -**注意:**内部类很多地方都很绕,所以说一定要仔细思考。 - -### 成员内部类 - -我们可以直接在类的内部定义成员内部类: - -```java -public class Test { - public class Inner { //内部类也是类,所以说里面也可以有成员变量、方法等,甚至还可以继续套娃一个成员内部类 - public void test(){ - System.out.println("我是成员内部类!"); - } - } -} -``` - -成员内部类和成员方法、成员变量一样,是对象所有的,而不是类所有的,如果我们要使用成员内部类,那么就需要: - -```java -public static void main(String[] args) { - Test test = new Test(); //我们首先需要创建对象 - Test.Inner inner = test.new Inner(); //成员内部类的类型名称就是 外层.内部类名称 -} -``` - -虽然看着很奇怪,但是确实是这样使用的。我们同样可以使用成员内部类中的方法: - -```java -public static void main(String[] args) { - Test test = new Test(); - Test.Inner inner = test.new Inner(); - inner.test(); -} -``` - -注意,成员内部类也可以使用访问权限控制,如果我们我们将其权限改为`private`,那么就像我们把成员变量访问权限变成私有一样,外部是无法访问到这个内部类的: - -![image-20220924122217070](https://s2.loli.net/2022/09/24/HklipN4uOfK9JrG.png) - -可以看到这里直接不认识了。 - -这里我们需要特别注意一下,在成员内部类中,是可以访问到外层的变量的: - -```java -public class Test { - private final String name; - - public Test(String name){ - this.name = name; - } - public class Inner { - public void test(){ - System.out.println("我是成员内部类:"+name); - //成员内部类可以访问到外部的成员变量 - //因为成员内部类本身就是某个对象所有的,每个对象都有这样的一个类定义,这里的name是其所依附对象的 - } - } -} -``` - -![image-20220924123600217](https://s2.loli.net/2022/09/24/aQPow8piljRCs2d.png) - -每个类可以创建一个对象,每个对象中都有一个单独的类定义,可以通过这个成员内部类又创建出更多对象,套娃了属于是。 - -所以说我们在使用时: - -```java -public static void main(String[] args) { - Test a = new Test("小明"); - Test.Inner inner1 = a.new Inner(); //依附于a创建的对象,那么就是a的 - inner1.test(); - - Test b = new Test("小红"); - Test.Inner inner2 = b.new Inner(); //依附于b创建的对象,那么就是b的 - inner2.test(); -} -``` - -那现在问大家一个问题,外部能访问内部类里面的成员变量吗? - -那么如果内部类中也定义了同名的变量,此时我们怎么去明确要使用的是哪一个呢? - -```java -public class Test { - private final String name; - - public Test(String name){ - this.name = name; - } - public class Inner { - - String name; - public void test(String name){ - System.out.println("方法参数的name = "+name); //依然是就近原则,最近的是参数,那就是参数了 - System.out.println("成员内部类的name = "+this.name); //在内部类中使用this关键字,只能表示内部类对象 - System.out.println("成员内部类的name = "+Test.this.name); - //如果需要指定为外部的对象,那么需要在前面添加外部类型名称 - } - } -} -``` - -包括对方法的调用和super关键字的使用,也是一样的: - -```java -public class Inner { - - String name; - public void test(String name){ - this.toString(); //内部类自己的toString方法 - super.toString(); //内部类父类的toString方法 - Test.this.toString(); //外部类的toSrting方法 - Test.super.toString(); //外部类父类的toString方法 - } -} -``` - -所以说成员内部类其实在某些情况下使用起来比较麻烦,对于这种成员内部类,我们一般只会在类的内部自己使用。 - -### 静态内部类 - -前面我们介绍了成员内部类,它就像成员变量和成员方法一样,是属于对象的,同样的,静态内部类就像静态方法和静态变量一样,是属于类的,我们可以直接创建使用。 - -```java -public class Test { - private final String name; - - public Test(String name){ - this.name = name; - } - - public static class Inner { - public void test(){ - System.out.println("我是静态内部类!"); - } - } -} -``` - -不需要依附任何对象,我们可以直接创建静态内部类的对象: - -```java -public static void main(String[] args) { - Test.Inner inner = new Test.Inner(); //静态内部类的类名同样是之前的格式,但是可以直接new了 - inner.test(); -} -``` - -静态内部类由于是静态的,所以相对外部来说,整个内部类中都处于静态上下文(注意只是相当于外部来说)是无法访问到外部类的非静态内容的: - -![image-20220924124919135](https://s2.loli.net/2022/09/24/cZapwgeATlG2FHn.png) - -只不过受影响的只是外部内容的使用,内部倒是不受影响,还是跟普通的类一样: - -```java -public static class Inner { - - String name; - public void test(){ - System.out.println("我是静态内部类:"+name); - } -} -``` - -其实也很容易想通,因为静态内部类是属于外部类的,不依附任何对象,那么我要是直接访问外部类的非静态属性,那到底访问哪个对象的呢?这样肯定是说不通的。 - -### 局部内部类 - -局部内部类就像局部变量一样,可以在方法中定义。 - -```java -public class Test { - private final String name; - - public Test(String name){ - this.name = name; - } - - public void hello(){ - class Inner { //直接在方法中创建局部内部类 - - } - } -} -``` - -既然是在方法中声明的类,那作用范围也就只能在方法中了: - -```java -public class Test { - public void hello(){ - class Inner{ //局部内部类跟局部变量一样,先声明后使用 - public void test(){ - System.out.println("我是局部内部类"); - } - } - - Inner inner = new Inner(); //局部内部类直接使用类名就行 - inner.test(); - } -} -``` - -只不过这种局部内部类的形式,使用频率很低,基本上不会用到,所以说了解就行了。 - -### 匿名内部类 - -匿名内部类是我们使用频率非常高的一种内部类,它是局部内部类的简化版。 - -还记得我们在之前学习的抽象类和接口吗?在抽象类和接口中都会含有某些抽象方法需要子类去实现,我们当时已经很明确地说了不能直接通过new的方式去创建一个抽象类或是接口对象,但是我们可以使用匿名内部类。 - -```java -public abstract class Student { - public abstract void test(); -} -``` - -正常情况下,要创建一个抽象类的实例对象,只能对其进行继承,先实现未实现的方法,然后创建子类对象。 - -而我们可以在方法中使用匿名内部类,将其中的抽象方法实现,并直接创建实例对象: - -```java -public static void main(String[] args) { - Student student = new Student() { //在new的时候,后面加上花括号,把未实现的方法实现了 - @Override - public void test() { - System.out.println("我是匿名内部类的实现!"); - } - }; - student.test(); -} -``` - -此时这里创建出来的Student对象,就是一个已经实现了抽象方法的对象,这个抽象类直接就定义好了,甚至连名字都没有,就可以直接就创出对象。 - -匿名内部类中同样可以使用类中的属性(因为它本质上就相当于是对应类型的子类)所以说: - -```java -Student student = new Student() { - int a; //因为本质上就相当于是子类,所以说子类定义一些子类的属性完全没问题 - - @Override - public void test() { - System.out.println(name + "我是匿名内部类的实现!"); //直接使用父类中的name变量 - } -}; -``` - -同样的,接口也可以通过这种匿名内部类的形式,直接创建一个匿名的接口实现类: - -```java -public static void main(String[] args) { - Study study = new Study() { - @Override - public void study() { - System.out.println("我是学习方法!"); - } - }; - study.study(); -} -``` - -当然,并不是说只有抽象类和接口才可以像这样创建匿名内部类,普通的类也可以,只不过意义不大,一般情况下只是为了进行一些额外的初始化工作而已。 - -### Lambda表达式 - -前面我们介绍了匿名内部类,我们可以通过这种方式创建一个临时的实现子类。 - -特别的,**如果一个接口中有且只有一个待实现的抽象方法**,那么我们可以将匿名内部类简写为Lambda表达式: - -```java -public static void main(String[] args) { - Study study = () -> System.out.println("我是学习方法!"); //是不是感觉非常简洁! - study.study(); -} -``` - -在初学阶段,为了简化学习,各位小伙伴就认为Lambda表达式就是匿名内部类的简写就行了(Lambda表达式的底层其实并不只是简简单单的语法糖替换,感兴趣的可以在新特性篇视频教程中了解) - -那么它是一个怎么样的简写规则呢?我们来看一下Lambda表达式的具体规范: - -- 标准格式为:`([参数类型 参数名称,]...) ‐> { 代码语句,包括返回值 }` -- 和匿名内部类不同,Lambda仅支持接口,不支持抽象类 -- 接口内部必须有且仅有一个抽象方法(可以有多个方法,但是必须保证其他方法有默认实现,必须留一个抽象方法出来) - -比如我们之前写的Study接口,只要求实现一个无参无返回值的方法,所以说直接就是最简单的形式: - -```java -() -> System.out.println("我是学习方法!"); //跟之前流程控制一样,如果只有一行代码花括号可省略 -``` - -当然,如果有一个参数和返回值的话: - -```java -public static void main(String[] args) { - Study study = (a) -> { - System.out.println("我是学习方法"); - return "今天学会了"+a; //实际上这里面就是方法体,该咋写咋写 - }; - System.out.println(study.study(10)); -} -``` - -注意,如果方法体中只有一个返回语句,可以直接省去花括号和`return`关键字: - -```java -Study study = (a) -> { - return "今天学会了"+a; //这种情况是可以简化的 -}; -``` - -```java -Study study = (a) -> "今天学会了"+a; -``` - -如果参数只有一个,那么可以省去小括号: - -```java -Study study = a -> "今天学会了"+a; -``` - -是不是感觉特别简洁,实际上我们程序员追求的就是写出简洁高效的代码,而Java也在朝这个方向一直努力,近年来从Java 9开始出现的一些新语法基本都是各种各样的简写版本。 - -如果一个方法的参数需要的是一个接口的实现: - -```java -public static void main(String[] args) { - test(a -> "今天学会了"+a); //参数直接写成lambda表达式 -} - -private static void test(Study study){ - study.study(10); -} -``` - -当然,这还只是一部分,对于已经实现的方法,如果我们想直接作为接口抽象方法的实现,我们还可以使用方法引用。 - -### 方法引用 - -方法引用就是将一个已实现的方法,直接作为接口中抽象方法的实现(当然前提是方法定义得一样才行) - -```java -public interface Study { - int sum(int a, int b); //待实现的求和方法 -} -``` - -那么使用时候,可以直接使用Lambda表达式: - -```java -public static void main(String[] args) { - Study study = (a, b) -> a + b; -} -``` - -只不过还能更简单,因为Integer类中默认提供了求两个int值之和的方法: - -```java -//Integer类中就已经有对应的实现了 -public static int sum(int a, int b) { - return a + b; -} -``` - -此时,我们可以直接将已有方法的实现作为接口的实现: - -```java -public static void main(String[] args) { - Study study = (a, b) -> Integer.sum(a, b); //直接使用Integer为我们通过好的求和方法 - System.out.println(study.sum(10, 20)); -} -``` - -我们发现,Integer.sum的参数和返回值,跟我们在Study中定义的完全一样,所以说我们可以直接使用方法引用: - -```java -public static void main(String[] args) { - Study study = Integer::sum; //使用双冒号来进行方法引用,静态方法使用 类名::方法名 的形式 - System.out.println(study.sum(10, 20)); -} -``` - -方法引用其实本质上就相当于将其他方法的实现,直接作为接口中抽象方法的实现。任何方法都可以通过方法引用作为实现: - -```java -public interface Study { - String study(); -} -``` - -如果是普通从成员方法,我们同样需要使用对象来进行方法引用: - -```java -public static void main(String[] args) { - Main main = new Main(); - Study study = main::lbwnb; //成员方法因为需要具体对象使用,所以说只能使用 对象::方法名 的形式 -} - -public String lbwnb(){ - return "卡布奇诺今犹在,不见当年倒茶人。"; -} -``` - -因为现在只需要一个String类型的返回值,由于String的构造方法在创建对象时也会得到一个String类型的结果,所以说: - -```java -public static void main(String[] args) { - Study study = String::new; //没错,构造方法也可以被引用,使用new表示 -} -``` - -反正只要是符合接口中方法的定义的,都可以直接进行方法引用,对于Lambda表达式和方法引用,在Java新特性介绍篇视频教程中还有详细的讲解,这里就不多说了。 - -*** - -## 异常机制 - -在理想的情况下,我们的程序会按照我们的思路去运行,按理说是不会出现问题的,但是,代码实际编写后并不一定是完美的,可能会有我们没有考虑到的情况,如果这些情况能够正常得到一个错误的结果还好,但是如果直接导致程序运行出现问题了呢? - -```java -public static void main(String[] args) { - test(1, 0); //当b为0的时候,还能正常运行吗? -} - -private static int test(int a, int b){ - return a/b; //没有任何的判断而是直接做计算 -} -``` - -此时我们可以看到,出现了运算异常: - -![image-20220924164357033](https://s2.loli.net/2022/09/24/5PxTJv7M2YFzfg4.png) - -那么这个异常到底是什么样的一种存在呢?当程序运行出现我们没有考虑到的情况时,就有可能出现异常或是错误! - -### 异常的类型 - -我们在之前其实已经接触过一些异常了,比如数组越界异常,空指针异常,算术异常等,他们其实都是异常类型,我们的每一个异常也是一个类,他们都继承自`Exception`类!异常类型本质依然类的对象,但是异常类型支持在程序运行出现问题时抛出(也就是上面出现的红色报错)也可以提前声明,告知使用者需要处理可能会出现的异常! - -异常的第一种类型是运行时异常,如上述的列子,在编译阶段无法感知代码是否会出现问题,只有在运行的时候才知道会不会出错(正常情况下是不会出错的),这样的异常称为运行时异常,异常也是由类定义的,所有的运行时异常都继承自`RuntimeException`。 - -```java -public static void main(String[] args) { - Object object = null; - object.toString(); //这种情况就会出现运行时异常 -} -``` - -![image-20220924164637887](https://s2.loli.net/2022/09/24/cTAqbZ93HidRIGW.png) - -又比如下面的这种情况: - -```java -public static void main(String[] args) { - Object object = new Object(); - Main main = (Main) object; -} -``` - -![image-20220924164844005](https://s2.loli.net/2022/09/24/QxMimbjZk19C25d.png) - -异常的另一种类型是编译时异常,编译时异常明确指出可能会出现的异常,在编译阶段就需要进行处理(捕获异常)必须要考虑到出现异常的情况,如果不进行处理,将无法通过编译!默认继承自`Exception`类的异常都是编译时异常。 - -```java -protected native Object clone() throws CloneNotSupportedException; -``` - -比如Object类中定义的`clone`方法,就明确指出了在运行的时候会出现的异常。 - -还有一种类型是错误,错误比异常更严重,异常就是不同寻常,但不一定会导致致命的问题,而错误是致命问题,一般出现错误可能JVM就无法继续正常运行了,比如`OutOfMemoryError`就是内存溢出错误(内存占用已经超出限制,无法继续申请内存了) - -```java -public static void main(String[] args) { - test(); -} - -private static void test(){ - test(); -} -``` - -比如这样的一个无限递归的方法,会导致运行过程中无限制地向下调用方法,导致栈溢出: - -![image-20220924165500108](https://s2.loli.net/2022/09/24/9YEZV2L73ROQTuA.png) - -这种情况就是错误了,已经严重到整个程序都无法正常运行了。又比如: - -```java -public static void main(String[] args) { - Object[] objects = new Object[Integer.MAX_VALUE]; //这里申请一个超级大数组 -} -``` - -实际上我们电脑的内存是有限的,不可能无限制地使用内存来存放变量,所以说如果内存不够用了,会直接: - -![image-20220924165657392](https://s2.loli.net/2022/09/24/qj8zJnGxdS5IybX.png) - -此时没有更多的可用内存供我们的程序使用,那么程序也就没办法继续运行下去了,这同样是一个很严重的错误。 - -当然,我们这一块主要讨论的目录依然是异常。 - -### 自定义异常 - -异常其实就两大类,一个是编译时异常,一个是运行时异常,我们先来看编译时异常。 - -```java -public class TestException extends Exception{ - public TestException(String message){ - super(message); //这里我们选择使用父类的带参构造,这个参数就是异常的原因 - } -} -``` - -编译时异常只需要继承Exception就行了,编译时异常的子类有很多很多,仅仅是SE中就有700多个。 - -![image-20220924202450589](https://s2.loli.net/2022/09/24/TzUu5Sk6NycB9An.png) - -异常多种多样,不同的异常对应着不同的情况,比如在类型转换时出错那么就是类型转换异常,如果是使用一个值为null的变量调用方法,那么就会出现空指针异常。 - -运行时异常只需要继承RuntimeException就行了: - -```java -public class TestException extends RuntimeException{ - public TestException(String message){ - super(message); - } -} -``` - -RuntimeException继承自Exception,Exception继承自Throwable: - -![image-20220924203130042](https://s2.loli.net/2022/09/24/RjzWnNDc6TZeSoJ.png) - -运行时异常同同样也有很多,只不过运行时异常和编译型异常在使用时有一些不同,我们会在后面的学习中慢慢认识。 - -当然还有一种类型是Error,它是所有错误的父类,同样是继承自Throwable的。 - -### 抛出异常 - -当别人调用我们的方法时,如果传入了错误的参数导致程序无法正常运行,这时我们就可以手动抛出一个异常来终止程序继续运行下去,同时告知上一级方法执行出现了问题: - -```java -public static int test(int a, int b) { - if(b == 0) - throw new RuntimeException("被除数不能为0"); //使用throw关键字来抛出异常 - return a / b; -} -``` - -异常的抛出同样需要创建一个异常对象出来,我们抛出异常实际上就是将这个异常对象抛出,异常对象携带了我们抛出异常时的一些信息,比如是因为什么原因导致的异常,在RuntimeException的构造方法中我们可以写入原因。 - -当出现异常时: - -![image-20220924200817314](https://s2.loli.net/2022/09/24/Ttr4kZSyodKi3M8.png) - -程序会终止,并且会打印栈追踪信息,因为各位小伙伴才初学,还不知道什么是栈,我们这里就简单介绍一下,实际上方法之间的调用是有层级关系的,而当异常发生时,方法调用的每一层都会在栈追踪信息中打印出来,比如这里有两个`at`,实际上就是在告诉我们程序运行到哪个位置时出现的异常,位于最上面的就是发生异常的最核心位置,我们代码的第15行。 - -并且这里会打印出当前抛出的异常类型和我们刚刚自定义异常信息。 - -注意,如果我们在方法中抛出了一个非运行时异常,那么必须告知函数的调用方我们会抛出某个异常,函数调用方必须要对抛出的这个异常进行对应的处理才可以: - -```java -private static void test() throws Exception { //使用throws关键字告知调用方此方法会抛出哪些异常,请调用方处理好 - throw new Exception("我是编译时异常!"); -} -``` - -注意,如果不同的分支条件会出现不同的异常,那么所有在方法中可能会抛出的异常都需要注明: - -```java -private static void test(int a) throws FileNotFoundException, ClassNotFoundException { //多个异常使用逗号隔开 - if(a == 1) - throw new FileNotFoundException(); - else - throw new ClassNotFoundException(); -} -``` - -当然,并不是只有非运行时异常可以像这样明确指出,运行时异常也可以,只不过不强制要求: - -```java -private static void test(int a) throws RuntimeException { - throw new RuntimeException(); -} -``` - -至于如何处理明确抛出的异常,我们会下一个部分中进行讲解。 - -最后再提一下,我们在重写方法时,如果父类中的方法表明了会抛出某个异常,只要重写的内容中不会抛出对应的异常我们可以直接省去: - -```java -@Override -protected Object clone() { - return new Object(); -} -``` - -### 异常的处理 - -当程序没有按照我们理想的样子运行而出现异常时(默认会交给JVM来处理,JVM发现任何异常都会立即终止程序运行,并在控制台打印栈追踪信息)现在我们希望能够自己处理出现的问题,让程序继续运行下去,就需要对异常进行捕获,比如: - -```java -public static void main(String[] args) { - try { //使用try-catch语句进行异常捕获 - Object object = null; - object.toString(); - } catch (NullPointerException e){ //因为异常本身也是一个对象,catch中实际上就是用一个局部变量去接收异常 - - } - System.out.println("程序继续正常运行!"); -} -``` - -我们可以将代码编写到`try`语句块中,只要是在这个范围内发生的异常,都可以被捕获,使用`catch`关键字对指定的异常进行捕获,这里我们捕获的是NullPointerException空指针异常: - -![image-20220924195434572](https://s2.loli.net/2022/09/24/7Ek5A46QHNKtWoJ.png) - -可以看到,当我们捕获异常之后,程序可以继续正常运行,并不会像之前一样直接结束掉。 - -注意,catch中捕获的类型只能是Throwable的子类,也就是说要么是抛出的异常,要么是错误,不能是其他的任何类型。 - -我们可以在`catch`语句块中对捕获到的异常进行处理: - -```java -public static void main(String[] args) { - try { - Object object = null; - object.toString(); - } catch (NullPointerException e){ - e.printStackTrace(); //打印栈追踪信息 - System.out.println("异常错误信息:"+e.getMessage()); //获取异常的错误信息 - } - System.out.println("程序继续正常运行!"); -} -``` - -![image-20220924201405697](https://s2.loli.net/2022/09/24/d15ns6hQblU8TAS.png) - -如果某个方法明确指出会抛出哪些异常,除非抛出的异常是一个运行时异常,否则我们必须要使用try-catch语句块进行异常的捕获,不然就无法通过编译: - -```java -public static void main(String[] args) { - test(10); //必须要进行异常的捕获,否则报错 -} - -private static void test(int a) throws IOException { //明确会抛出IOException - throw new IOException(); -} -``` - -当然,如果我们确实不想在当前这个方法中进行处理,那么我们可以继续踢皮球,抛给上一级: - -```java -public static void main(String[] args) throws IOException { //继续编写throws往上一级抛 - test(10); -} - -private static void test(int a) throws IOException { - throw new IOException(); -} -``` - -注意,如果已经是主方法了,那么就相当于到顶层了,此时发生异常再往上抛出的话,就会直接交给JVM进行处理,默认会让整个程序终止并打印栈追踪信息。 - -注意,如果我们要捕获的异常,是某个异常的父类,那么当发生这个异常时,同样可以捕获到: - -```java -public static void main(String[] args) throws IOException { - try { - int[] arr = new int[1]; - arr[1] = 100; //这里发生的是数组越界异常,它是运行时异常的子类 - } catch (RuntimeException e){ //使用运行时异常同样可以捕获到 - System.out.println("捕获到异常"); - } -} -``` - -当代码可能出现多种类型的异常时,我们希望能够分不同情况处理不同类型的异常,就可以使用多重异常捕获: - -```java -try { - //.... -} catch (NullPointerException e) { - -} catch (IndexOutOfBoundsException e){ - -} catch (RuntimeException e){ - -} -``` - -但是要注意一下顺序: - -```java -try { - //.... -} catch (RuntimeException e){ //父类型在前,会将子类的也捕获 - -} catch (NullPointerException e) { //永远都不会被捕获 - -} catch (IndexOutOfBoundsException e){ //永远都不会被捕获 - -} -``` - -只不过这样写好像有点丑,我们也可以简写为: - -```java -try { - //.... -} catch (NullPointerException | IndexOutOfBoundsException e) { //用|隔开每种类型即可 - -} -``` - -如果简写的话,那么发生这些异常的时候,都会采用统一的方式进行处理了。 - -最后,当我们希望,程序运行时,无论是否出现异常,都会在最后执行任务,可以交给`finally`语句块来处理: - -```java -try { - //.... -}catch (Exception e){ - -}finally { - System.out.println("lbwnb"); //无论是否出现异常,都会在最后执行 -} -``` - -`try`语句块至少要配合`catch`或`finally`中的一个: - -```java -try { - int a = 10; - a /= 0; -} finally { //不捕获异常,程序会终止,但在最后依然会执行下面的内容 - System.out.println("lbwnb"); -} -``` - -**思考:**`try`、`catch`和`finally`执行顺序? - -### 断言表达式 - -我们可以使用断言表达式来对某些东西进行判断,如果判断失败会抛出错误,只不过默认情况下没有开启断言,我们需要在虚拟机参数中手动开启一下: - -![image-20220924220327591](https://s2.loli.net/2022/09/24/cAG8kY395fOuTLg.png) - -开启断言之后,我们就可以开始使用了。 - -断言表达式需要使用到`assert`关键字,如果assert后面的表达式判断结果为false,将抛出AssertionError错误。 - -```java -public static void main(String[] args) { - assert false; -} -``` - -比如我们可以判断变量的值,如果大于10就抛出错误: - -```java -public static void main(String[] args) { - int a = 10; - assert a > 10; -} -``` - -![image-20220924220704026](https://s2.loli.net/2022/09/24/12b6zRAL3evQ9ZB.png) - -我们可以在表达式的后面添加错误信息: - -```java -public static void main(String[] args) { - int a = 10; - assert a > 10 : "我是自定义的错误信息"; -} -``` - -这样就会显示到错误后面了: - -![image-20220924220813609](https://s2.loli.net/2022/09/24/NaYk5pFiBPLXVIr.png) - -断言表达式一般只用于测试,我们正常的程序中一般不会使用,这里只做了解就行了。 - -*** - -## 常用工具类介绍 - -前面我们学习了包装类、数组和字符串,我们接着来看看常用的一些工具类。工具类就是专门为一些特定场景编写的,便于我们去使用的类,工具类一般都会内置大量的静态方法,我们可以通过类名直接使用。 - -### 数学工具类 - -Java提供的运算符实际上只能进行一些在小学数学中出现的运算,但是如果我们想要进行乘方、三角函数之类的高级运算,就没有对应的运算符能够做到,而此时我们就可以使用数学工具类来完成。 - -```java -public static void main(String[] args) { - //Math也是java.lang包下的类,所以说默认就可以直接使用 - System.out.println(Math.pow(5, 3)); //我们可以使用pow方法直接计算a的b次方 - - Math.abs(-1); //abs方法可以求绝对值 - Math.max(19, 20); //快速取最大值 - Math.min(2, 4); //快速取最小值 - Math.sqrt(9); //求一个数的算术平方根 -} -``` - -当然,三角函数肯定也是安排上了的: - -```java -Math.sin(Math.PI / 2); //求π/2的正弦值,这里我们可以使用预置的PI进行计算 -Math.cos(Math.PI); //求π的余弦值 -Math.tan(Math.PI / 4); //求π/4的正切值 - -Math.asin(1); //三角函数的反函数也是有的,这里是求arcsin1的值 -Math.acos(1); -Math.atan(0); -``` - -可能在某些情况下,计算出来的浮点数会得到一个很奇怪的结果: - -```java -public static void main(String[] args) { - System.out.println(Math.sin(Math.PI)); //计算 sinπ 的结果 -} -``` - -![image-20220923231536032](https://s2.loli.net/2022/09/23/fZ6OVRejDXWSalC.png) - -正常来说,sinπ的结果应该是0才对,为什么这里得到的是一个很奇怪的数?这个E是干嘛的,这其实是科学计数法的10,后面的数就是指数,上面的结果其实就是: - -* $1.2246467991473532 \times 10^{-16}$ - -其实这个数是非常接近于0,这是因为精度问题导致的,所以说实际上结果就是0。 - -我们也可以快速计算对数函数: - -```java -public static void main(String[] args) { - Math.log(Math.E); //e为底的对数函数,其实就是ln,我们可以直接使用Math中定义好的e - Math.log10(100); //10为底的对数函数 - //利用换底公式,我们可以弄出来任何我们想求的对数函数 - double a = Math.log(4) / Math.log(2); //这里是求以2为底4的对数,log(2)4 = ln4 / ln2 - System.out.println(a); -} -``` - -还有一些比较特殊的计算: - -```java -public static void main(String[] args) { - Math.ceil(4.5); //通过使用ceil来向上取整 - Math.floor(5.6); //通过使用floor来向下取整 -} -``` - -向上取整就是找一个大于当前数字的最小整数,向下取整就是砍掉小数部分。注意,如果是负数的话,向上取整就是去掉小数部分,向下取整就是找一个小于当前数字的最大整数。 - -这里我们再介绍一下随机数的生成,Java中想要生成一个随机数其实也很简单,我们需要使用Random类来生成(这个类时java.util包下的,需要手动导入才可以) - -```java -public static void main(String[] args) { - Random random = new Random(); //创建Random对象 - for (int i = 0; i < 30; i++) { - System.out.print(random.nextInt(100)+" "); //nextInt方法可以指定创建0 - x之内的随机数 - } -} -``` - -结果为,可以看到确实是一堆随机数: - -![image-20220923234642670](https://s2.loli.net/2022/09/23/fM8J7zO2qHXhvst.png) - -只不过,程序中的随机并不是真随机,而是根据某些东西计算出来的,只不过计算过程非常复杂,能够在一定程度上保证随机性(根据爱因斯坦理论,宏观物质世界不存在真随机,看似随机的事物只是现目前无法计算而已,唯物主义的公理之一就是任何事物都有因果关系) - -### 数组工具类 - -前面我们介绍了数组,但是我们发现,想要操作数组实在是有点麻烦,比如我们要打印一个数组,还得一个一个元素遍历才可以,那么有没有一个比较方便的方式去使用数组呢?我们可以使用数组工具类Arrays。 - -这个类也是`java.util`包下类,它用于便捷操作数组,比如我们想要打印数组,可以直接通过toString方法转换字符串: - -```java -public static void main(String[] args) { - int[] arr = new int[]{1, 4, 5, 8, 2, 0, 9, 7, 3, 6}; - System.out.println(Arrays.toString(arr)); -} -``` - -![image-20220923235747731](https://s2.loli.net/2022/09/23/fx61nKT7LjdMv5q.png) - -是不是感觉非常方便?这样我们直接就可以打印数组了! - -除了这个方法,它还支持将数组进行排序: - -```java -public static void main(String[] args) { - int[] arr = new int[]{1, 4, 5, 8, 2, 0, 9, 7, 3, 6}; - Arrays.sort(arr); //可以对数组进行排序,将所有的元素按照从小到大的顺序排放 - System.out.println(Arrays.toString(arr)); -} -``` - -感兴趣的小伙伴可以在数据结构与算法篇视频教程中了解多种多样的排序算法,这里的排序底层实现实际上用到了多种排序算法。 - -数组中的内容也可以快速进行填充: - -```java -public static void main(String[] args) { - int[] arr = new int[10]; - Arrays.fill(arr, 66); - System.out.println(Arrays.toString(arr)); -} -``` - -我们可以快速地对一个数组进行拷贝: - -```java -public static void main(String[] args) { - int[] arr = new int[]{1, 2, 3, 4, 5}; - int[] target = Arrays.copyOf(arr, 5); - System.out.println(Arrays.toString(target)); //拷贝数组的全部内容,并生成一个新的数组对象 - System.out.println(arr == target); -} -``` - -```java -public static void main(String[] args) { - int[] arr = new int[]{1, 2, 3, 4, 5}; - int[] target = Arrays.copyOfRange(arr, 3, 5); //也可以只拷贝某个范围内的内容 - System.out.println(Arrays.toString(target)); - System.out.println(arr == target); -} -``` - -我们也可以将一个数组中的内容拷贝到其他数组中: - -```java -public static void main(String[] args) { - int[] arr = new int[]{1, 2, 3, 4, 5}; - int[] target = new int[10]; - System.arraycopy(arr, 0, target, 0, 5); //使用System.arraycopy进行搬运 - System.out.println(Arrays.toString(target)); -} -``` - -对于一个有序的数组(从小到大排列)我们可以使用二分搜索快速找到对应的元素在哪个位置: - -```java -public static void main(String[] args) { - int[] arr = new int[]{1, 2, 3, 4, 5}; - System.out.println(Arrays.binarySearch(arr, 5)); //二分搜索仅适用于有序数组 -} -``` - -这里提到了二分搜索算法,我们会在后面的实战练习中进行讲解。 - -那要是现在我们使用的是多维数组呢?因为现在数组里面的每个元素就是一个数组,所以说toString会出现些问题: - -```java -public static void main(String[] args) { - int[][] array = new int[][]{{2, 8, 4, 1}, {9, 2, 0, 3}}; - System.out.println(Arrays.toString(array)); -} -``` - -![image-20220924114142785](https://s2.loli.net/2022/09/24/L2at7HJi3BKf6jF.png) - -只不过别担心,Arrays也支持对多维数组进行处理: - -```java -public static void main(String[] args) { - int[][] array = new int[][]{{2, 8, 4, 1}, {9, 2, 0, 3}}; - System.out.println(Arrays.deepToString(array)); //deepToString方法可以对多维数组进行打印 -} -``` - -同样的,因为数组本身没有重写equals方法,所以说无法判断两个不同的数组对象中的每一个元素是否相同,Arrays也为一维数组和多维数组提供了相等判断的方法: - -```java -public static void main(String[] args) { - int[][] a = new int[][]{{2, 8, 4, 1}, {9, 2, 0, 3}}; - int[][] b = new int[][]{{2, 8, 4, 1}, {9, 2, 0, 3}}; - System.out.println(Arrays.equals(a, b)); //equals仅适用于一维数组 - System.out.println(Arrays.deepEquals(a, b)); //对于多维数组,需要使用deepEquals来进行深层次判断 -} -``` - -这里肯定有小伙伴疑问了,不是说基本类型的数组不能转换为引用类型的数组吗?为什么这里的deepEquals接受的是`Object[]`也可以传入参数呢?这是因为现在是二维数组,二维数组每个元素都是一个数组,而数组本身的话就是一个引用类型了,所以说可以转换为Object类型,但是如果是一维数组的话,就报错: - -![image-20220924115440998](https://s2.loli.net/2022/09/24/ab94eNcJPERlOYA.png) - -总体来说,这个工具类对于我们数组的使用还是很方便的。 - -*** - -## 实战练习 - -到目前为止,关于面向对象相关的内容我们已经学习了非常多了,接着依然是练习题。 - -### 冒泡排序算法 - -有一个int数组,但是数组内的数据是打乱的,现在我们需要将数组中的数据按**从小到大**的顺序进行排列: - -```java -public static void main(String[] args) { - int[] arr = new int[]{3, 5, 7, 2, 9, 0, 6, 1, 8, 4}; -} -``` - -请你设计一个Java程序将这个数组中的元素按照顺序排列。 - -```java -import java.lang.reflect.Array; -import java.util.Arrays; - -public class Main { - public static void main(String[] args) { - int[] arr = new int[]{3, 5, 7, 2, 9, 0, 6, 1, 8, 4}; - sort(arr); - System.out.println(Arrays.toString(arr)); - } - - private static void sort(int[] arr) { - for (int i = 0; i < arr.length - 1; i++) { - //用于判断是否进行了交换,没有交换证明此时数组顺序已经排完,无需再进行排序 - boolean flag = false; - for (int j = 0; j < arr.length - i - 1; j++) { - if (arr[j] > arr[j + 1]) { - flag = true; - int tmp = arr[j + 1]; - arr[j + 1] = arr[j]; - arr[j] = tmp; - } - } - if (!flag) break; - } - } -} -``` - - - -### 二分搜索算法 - -现在有一个从小到大排序的数组,给你一个目标值`target`,现在我们想要找到这个值在数组中的对应下标,如果数组中没有这个数,请返回`-1`: - -```java -public static void main(String[] args) { - int[] arr = {1, 3, 4, 6, 7, 8, 10, 11, 13, 15}; - int target = 3; -} -``` - -请你设计一个Java程序实现这个功能。 - -```java -import java.lang.reflect.Array; -import java.util.Arrays; - -public class Main { - public static void main(String[] args) { - int[] arr = {1, 3, 4, 6, 7, 8, 10, 11, 13, 15}; - int target = 3; - System.out.println(search(arr, target) + 1); - } - - private static int search(int[] arr, int target) { - int left = 0, right = arr.length - 1; - while (left <= right) { - int mid = (left + right) / 2; - int i = arr[mid]; - if (i < target) - left = mid + 1; - else if (i > target) - right = mid - 1; - else - return mid; - } - return -1; - } -} -``` - - - -### 青蛙跳台阶问题 - -现在一共有n个台阶,一只青蛙每次只能跳一阶或是两阶,那么一共有多少种跳到顶端的方案? - -例如n=2,那么一共有两种方案,一次性跳两阶或是每次跳一阶。 - -现在请你设计一个Java程序,计算当台阶数为n的情况下,能够有多少种方案到达顶端。 - - - -### 回文串判断 - -“回文串”是一个正读和反读都一样的字符串,请你实现一个Java程序,判断用户输入的字符串(仅出现英文字符)是否为“回文”串。 - -> ABCBA 就是一个回文串,因为正读反读都是一样的 -> -> ABCA 就不是一个回文串,因为反着读不一样 - - - -### 汉诺塔求解 - -什么是汉诺塔? - -> **汉诺塔**(Tower of Hanoi),又称**河内塔**,是一个源于[印度](https://baike.baidu.com/item/印度/121904)古老传说的[益智玩具](https://baike.baidu.com/item/益智玩具/223159)。[大梵天](https://baike.baidu.com/item/大梵天/711550)创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着64片黄金圆盘。大梵天命令[婆罗门](https://baike.baidu.com/item/婆罗门/1796550)把圆盘从下面开始 -> -> **按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。** - -![img](https://s2.loli.net/2022/09/24/mMpDNwrKk6z3CIo.png) - -这三根柱子我们就依次命名为A、B、C,现在请你设计一个Java程序,计算N阶(n片圆盘)汉诺塔移动操作的每一步。 diff --git a/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(一).md b/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(一).md deleted file mode 100644 index 88ef1a9..0000000 --- a/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(一).md +++ /dev/null @@ -1,590 +0,0 @@ -# Java语法规范 - -所有的Java语句必须以`;`结尾! - -无论是`()`、`[]`还是`{}`,所有的括号必须一一匹配! - -主方法的代码只能写在`{}`中! - -# Java基础语法(面向过程) - -在学习面向对象之前,我们需要了解面向过程的编程思维,如果你学习过C语言和Python就会很轻松! - -## 变量和关键字 - -### 变量 - -变量就是一个可变的量,例如定义一个int类型的变量(int就是整数类型): - -```java -int a = 10; -a = 20; -a = 30; -``` - -我们能够随意更改它的值,也就是说它的值是随时可变的,我们称为变量。变量可以是类的变量,也可以是方法内部的局部变量(我们现阶段主要用局部变量,类变量在面向对象再讲解) - -变量和C语言中的变量不同,Java中的变量是存放在JVM管理的内存中,C语言的变量存放在内存(某些情况下需要手动释放内存,而Java会自动帮助我们清理变量占据的内存)Java和C++很类似,但是没有指针!Java也叫C++-- - -Java是强类型语言,只有明确定义了变量之后,你才能使用!一旦被指定某个数据类型,那么它将始终被认为是对应的类型(和JS不一样!) - -定义一个变量的格式如下: - -```java -[类型] [标识符(名字)] = [初始值(可选)] -int a = 10; -``` - -注意:标识符不能为以下内容: - -* 标识符以由大小写字母、数字、下划线(_)和美元符号($)组成,但是不能以数字开头。 -* 大小写敏感! -* 不能有空格、@、#、+、-、/ 等符号 -* 应该使用有意义的名称,达到见名知意的目的,最好以小写字母开头 -* 不可以是 true 和 false -* 不能与Java语言的关键字重名 - -### 关键字 - -![image-20210817150135886](/Users/nagocoler/Library/Application Support/typora-user-images/image-20210817150135886.png) - -包括基本数据类型、流程控制语句等,了解就行,不用去记,后面我们会一点一点带大家认识! - -### 常量 - -常量就是无法修改值的变量,常量的值,只能定义一次: - -```java -final int a = 10; -a = 10; //报错! -``` - -常量前面必须添加final关键字(C语言里面是const,虽然Java也有,但是不能使用!) - -这只是final关键字的第一个用法,后面还会有更多的用法。 - -### 注释 - -养成注释的好习惯,不然以后自己都看不懂自己的代码!注释包括单行注释和多行注释: - -```java -//我是单行注释 - -/** -* 我是 -* 多行注释 -*/ - -//TODO 待做标记 -``` - -*** - -## 基本数据类型 - -Java中的数据类型分为基本数据类型和引用类型两大类,引用类型我们在面向对象时再提,基本数据类型是重点中的重点!首先我们需要了解有哪些类型。然后,我们需要知道的,并不是他们的精度如何,能够表示的范围有多大,而是为什么Java会给我们定义这些类型,计算机是怎么表示这些类型的,这样我们才能够更好的记忆他们的精度、表示的范围大小。所以,我们从计算机原理的角度出发,带领大家走进Java的基本数据类型。 - -这一部分稍微有点烧脑,但是是重中之重,如果你掌握了这些,任何相关的面试题都难不倒你!(如果你学习过计算机组成原理就很好理解了) - -### 计算机中的二进制表示 - -在计算机中,所有的内容都是二进制形式表示。十进制是以10为进位,如9+1=10;二进制则是满2进位(因为我们的计算机是电子的,电平信号只有高位和低位,你也可以暂且理解为通电和不通电,高电平代表1,低电平代表0,由于只有0和1,因此只能使用2进制表示我们的数字!)比如1+1=10=2^1+0,一个位也叫一个bit,8个bit称为1字节,16个bit称为一个字,32个bit称为一个双字,64个bit称为一个四字,我们一般采用字节来描述数据大小。 - -十进制的7 -> 在二进制中为 111 = 2^2 + 2^1 + 2^0 - -现在有4个bit位,最大能够表示多大的数字呢? - -* 最小:0000 => 0 -* 最大:1111 => 2^3+2^2+2^1+2^0 => 8 + 4 + 2 + 1 = 15 - -在Java中,无论是小数还是整数,他们都要带有符号(和C语言不同,C语言有无符号数)所以,首位就作为我们的符号位,还是以4个bit为例,首位现在作为符号位(1代表负数,0代表正数): - -* 最小:1111 => -(2^2+2^1+2^0) => -7 -* 最大:0111 => +(2^2+2^1+2^0) => +7 => 7 - -现在,我们4bit能够表示的范围变为了-7~+7,这样的表示方式称为原码。 - -### 计算机中的加减法 - -#### 原码 - -虽然原码表示简单,但是原码在做加减法的时候,很麻烦!以4bit位为例: - -1+(-1) = 0001 + 1001 = 怎么让计算机去计算?(虽然我们知道该去怎么算,但是计算机不知道!) - -我们得创造一种更好的表示方式!于是我们引入了反码: - -#### 反码 - -- 正数的反码是其本身 -- 负数的反码是在其原码的基础上, 符号位不变,其余各个位取反 - -经过上面的定义,我们再来进行加减法: - -1+(-1) = 0001 + 1110 = 1111 => -0 (直接相加,这样就简单多了!) - -思考:1111代表-0,0000代表+0,在我们实数的范围内,0有正负之分吗? - -* 0既不是正数也不是负数,那么显然这样的表示依然不够合理! - -#### 补码 - -根据上面的问题,我们引入了最终的解决方案,那就是补码,定义如下: - -- 正数的补码就是其本身 (不变!) -- 负数的补码是在其原码的基础上, 符号位不变, 其余各位取反, 最后+1. (即在反码的基础上+1) - -其实现在就已经能够想通了,-0其实已经被消除了!我们再来看上面的运算: - -1+(-1) = 0001 + 1111 = (1)0000 => +0 (现在无论你怎么算,也不会有-0了!) - -所以现在,4bit位能够表示的范围是:-8~+7(Java使用的就是补码!) - -`以上内容是重点, 是一定要掌握的知识,这些知识是你在面试中的最终防线!有了这些理论基础,无论面试题如何变换,都能够通过理论知识来破解` - -*** - -### 整数类型 - -整数类型是最容易理解的类型!既然我们知道了计算机中的二进制数字是如何表示的,那么我们就可以很轻松的以二进制的形式来表达我们十进制的内容了。 - -在Java中,整数类型包括以下几个: - -* byte 字节型 (8个bit,也就是1个字节)范围:-128~+127 -* short 短整形(16个bit,也就是2个字节)范围:-32768~+32767 -* int 整形(32个bit,也就是4个字节)最常用的类型! -* long 长整形(64个bit,也就是8个字节)最后需要添加l或L - -long都装不下怎么办?BigInteger! - -数字已经达到byte的最大值了,还能加吗?为了便于理解,以4bit为例: - -0111 + 0001 = 1000 => -8(你没看错,就是这样!) - -整数还能使用8进制、16进制表示: - -* 十进制为15 = 八进制表示为017 = 十六进制表示为 0xF = 二进制表示 1111 (代码里面不能使用二进制!) - -### 字符类型和字符串 - -在Java中,存在字符类型,它能够代表一个字符: - -* char 字符型(16个bit,也就是2字节,它不带符号!)范围是0 ~ 65535 -* 使用Unicode表示就是:\u0000 ~ \uffff - -字符要用单引号扩起来!比如 char c = '淦'; - -字符其实本质也是数字,但是这些数字通过编码表进行映射,代表了不同的字符,比如字符`'A'`的ASCII码就是数字`65`,所以,char类型其实可以转换为上面的整数类型。 - -Java的char采用Unicode编码表(不是ASCII编码!),Unicode编码表包含ASCII的所有内容,同时还包括了全世界的语言,ASCII只有1字节,而Unicode编码是2字节,能够代表65536种文字,足以包含全世界的文字了!(我们编译出来的字节码文件也是使用Unicode编码的,所以利用这种特性,其实Java支持中文变量名称、方法名称甚至是类名) - -既然char只能代表一个字符,那怎么才能包含一句话呢?(关于数组,我们这里先不了解,数组我们放在面向对象章节讲解) - -String就是Java中的字符串类型(注意,它是一个类,创建出来的字符串本质是一个对象,不是我们的基本类型)字符串就像它的名字一样,代表一串字符,也就是一句完整的话。 - -字符串用双引号括起来!比如:String str = "一日三餐没烦恼"; - -### 小数类型 - -小数类型比较难理解(比较难理解指的是原理,不是使用)首先来看看Java中的小数类型包含哪些: - -* float 单精度浮点型 (32bit,4字节) -* double 双精度浮点型(64bit,8字节) - -思考:小数的范围该怎么定义呢?我们首先要了解的是小数在计算机里面是如何存放的: - -![image-20210817143234500](/Users/nagocoler/Library/Application Support/typora-user-images/image-20210817143234500.png) - -根据国际标准 IEEE 754,任意一个二进制浮点数 V 可以表示成下面的形式: -V = (-1)^S × M × 2^E -(1)(-1)^S 表示符号位,当 S=0,V 为正数;当 S=1,V 为负数。 -(2)M 表示有效数字,大于等于 1,小于 2,但整数部分的 1 不变,因此可以省略。(例如尾数为1111010,那么M实际上就是1.111010,尾数首位必须是1,1后面紧跟小数点,如果出现0001111这样的情况,去掉前面的0,移动1到首位;题外话:随着时间的发展,IEEE 754标准默认第一位为1,故为了能够存放更多数据,就舍去了第一位,比如保存1.0101 的时候, 只保存 0101,这样能够多存储一位数据) -(3)2^E 表示指数位。(用于移动小数点) - -比如: 对于十进制的 5.25 对应的二进制为:101.01,相当于:1.0101*2^2。所以,S 为 0,M 为 1.0101,E 为 2。所以,对于浮点类型,最大值和最小值不仅取决于符号和尾数,还有它的阶码。我们在这里就不去计算了,想了解的可以去搜索相关资料。 - -思考:就算double有64bit位数,但是依然存在精度限制,如果我要进行高精度的计算,怎么办?BigDecimal! - -### 布尔类型 - -布尔类型(boolean)只有`true`和`false`两种值,也就是要么为真,要么为假,布尔类型的变量通常用作流程控制判断语句。(C语言一般使用0表示false,除0以外的所有数都表示true)布尔类型占据的空间大小并未明确定义,而是根据不同的JVM会有不同的实现。 - -*** - -## 类型转换 - -### 隐式类型转换 - -隐式类型转换支持字节数小的类型自动转换为字节数大的类型,整数类型自动转换为小数类型,转换规则如下: - -* byte→short(char)→int→long→float→double - -问题:为什么long比float大,还能转换为float呢?小数的存储规则让float的最大值比long还大,只是可能会丢失某些位上的精度! - -所以,如下的代码就能够正常运行: - -```java -byte b = 9; -short s = b; -int i = s; -long l = i; -float f = l; -double d = f; -System.out.println(d); - -//输出 9.0 -``` - -### 显示类型转换 - -显示类型转换也叫做强制类型转换,也就是说,违反隐式转换的规则,牺牲精度强行进行类型转换。 - -```java -int i = 128; -byte b = (byte)i; -System.out.println(b); - -//输出 -128 -``` - -为什么结果是-128?精度丢失了! - -* int 类型的128表示:00000000 00000000 00000000 10000000 -* byte类型转换后表示:xxxxxxxx xxxxxxxx xxxxxxxx 10000000 => -128 - -### 数据类型自动提升 - -在参与运算时(也可以位于表达式中时,自增自减除外),所有的byte型、short型和char的值将被提升到int型: - -``` java -byte b = 105; -b = b + 1; //报错! -System.out.println(b); -``` - -这个特性是由 **Java虚拟机规范** 定义的,也是为了提高运行的效率。其他的特性还有: - -* 如果一个操作数是long型,计算结果就是long型 -* 如果一个操作数是float型,计算结果就是float型 -* 如果一个操作数是double型,计算结果就是double型 - -*** - -## 运算符 - -### 赋值和算术运算符 - -赋值运算符`=`是最常用的运算符,其实就是将我们等号右边的结果,传递给等号左边的变量,例如: - -```java -int a = 10; -int b = 1 + 8; -int c = 5 * 5; -``` - -算术运算符也就是我们在小学阶段学习的`+` `-` `*` `/` `%`,分别代表加减乘除还有取余,例如: - -```java -int a = 2; -int b = 3; -int c = a * b; -//结果为6 -``` - -需要注意的是,`+`还可以用作字符串连接符使用: - -```java -System.out.println("lbw" + "nb"); //lbwnb -``` - -当然,字符串可以直接连接其他类型,但是会全部当做字符串处理: - -```java -int a = 7, b = 15; -System.out.println("lbw" + a + b); //lbw715 -``` - -算术运算符还包括`++`和`--`也就是自增和自减,以自增为例: - -```java -int a = 10; -a++; -System.out.println(a); //输出为11 -``` - -自增自减运算符放在变量的前后的返回值是有区别的: - -```java -int a = 10; -System.out.println(a++); //10 (先返回值,再自增) -System.out.println(a); //11 -``` - -```java -int a = 10; -System.out.println(++a); //11 (先自增,再返回值) -System.out.println(a); //11 -``` - -```java -int a = 10; -int b = 2; -System.out.println(b+++a++); //猜猜看结果是多少 -``` - -为了使得代码更简洁,你还可以使用扩展的赋值运算符,包括`+=`、`-=`、`/=`、`*=`、`%=`,和自增自减类似,先执行运算,再返回结果,同时自身改变: - -```java -int a = 10; -System.out.println(a += 2); //等价于 a = a + 2 -``` - -### 关系运算符 - -关系运算符的结果只能是布尔类型,也就是要么为真要么为假,关系运算符包括: - -```java -> < == //大于小于等于 ->= <= != //大于等于,小于等于,不等于 -``` - -关系运算符一般只用于基本类型的比较,运算结果只能是boolean: - -```java -int a = 10; -int b = 2; -boolean x = a > b; -System.out.println(x); -//结果为 true -``` - -### 逻辑运算符 - -逻辑运算符两边只能是boolean类型或是关系/逻辑运算表达式,返回值只能是boolean类型!逻辑运算符包括: - -```java -&& //与运算,要求两边同时为true才能返回true -|| //或运算,要求两边至少要有一个为true才能返回true -! //非运算,一般放在表达式最前面,表达式用括号扩起来,表示对表达式的结果进行反转 -``` - -实际案例来看看: - -```java -int a = 10; -int b = 2; -boolean x = a > b && a < b; //怎么可能同时满足呢 -System.out.println(x); //false -``` - -```java -int a = 10; -int b = 2; -boolean x = a > b || a <= b; //一定有一个满足! -System.out.println(x); //true -``` - -```java -int a = 10; -int b = 2; -boolean x = !(a > b); //对结果进行反转,本来应该是true -System.out.println(x); //false -``` - -### 位运算符 - -```java -& //按位与,注意,返回的是运算后的同类型值,不是boolean! -| //按位或 -^ //按位异或 0 ^ 0 = 0 -~ //按位非 -``` - -按位运算实际上是根据值的二进制编码来计算结果,例如按位与,以4bit为例: - -0101 & 0100 = 0100 (只有同时为1对应位才得1) - -```java -int a = 7, b = 15; -System.out.println(a & b); //结果为7 -``` - -### 三目运算符 - -三目运算符其实是为了简化代码而生,可以根据条件是否满足来决定返回值,格式如下: - -```java -int a = 7, b = 15; -String str = a > b ? "行" : "不行"; // 判断条件(只能是boolean,或返回boolean的表达式) ? 满足的返回值 : 不满足的返回值 -System.out.println("汉堡做的行不行?"+str); //汉堡做的行不行?不行 -``` - -理解三目运算符,就很容易理解后面的if-else语句了。 - -*** - -## 流程控制 - -我们的程序都是从上往下依次运行的,但是,仅仅是这样还不够,我们需要更加高级的控制语句来帮我进行更灵活的控制。比如,判断用户输入的数字,大于1则输出ok,小于1则输出no,这时我们就需要用到选择结构来帮助我们完成条件的判断和程序的分支走向。学习过C语言就很轻松! - -### 选择结构 - -选择结构包含if和switch类型,选择结构能够帮助我们根据条件判断,再执行哪一块代码。 - -#### if语句 - -就像上面所说,判断用户输入的数字,大于1则输出ok,小于1则输出no,要实现这种效果,我们首先可以采用if语句: - -```java -if(判断条件){ - //判断成功执行的内容 -}else{ - //判断失败执行的内容 -} -//if的内容执行完成后,后面的内容正常执行 -``` - -其中,`else`语句不是必须的。 - -现在,又来了一个新的需求,用户输入的是1打印ok,输入2,打印yes,其他打印no,那么这样就需要我们进行多种条件的判断了,当然if能进行多分支判断: - -```java -if(判断条件1){ - //判断成功执行的内容 -}else if(判断条件2){ - //再次判断,如果判断成功执行的内容 -}else{ - //上面的都没成功,只能走这里 -} -``` - -同样,`else`语句不是必须的。 - -现在,又来了一个新的需求,用户输入1之后,在判断用户下一次输入的是什么,如果是1,打印yes,不是就打印no,这样就可以用嵌套if了: - -```java -if(判断条件1){ - //前提是判断条件1要成功才能进来! - if(判断条件2){ - //判断成功执行的内容 - }else{ - //判断失败执行的内容 - } -} -``` - -#### switch语句 - -我们不难发现,虽然`else-if`能解决多分支判断的问题,但是效率实在是太低了,多分支if采用的是逐级向下判断,显然费时费力,那么有没有一直更专业的解决多分支判断问题的东西呢? - -```java -switch(判断主体){ - case 值1: - //运行xxx - break; //break用于跳出switch语句,不添加会导致程序继续向下运行! - case 值2: - //运行xxx - break; - case 值3: - //运行xxx - break; -} -``` - -在上述语句中,只有判断主体等于case后面的值时,才会执行case中的语句,同时需要使用break来跳出switch语句,否则会继续向下运行! - -为什么switch效率更高呢,因为switch采用二分思想进行查找(这也是为什么switch只能判断值相等的原因),能够更快地找到我们想要的结果! - -### 循环结构 - -小明想向小红表白,于是他在屏幕上打印了520个 "我爱你",我们用Java该如何实现呢? - -#### for语句 - -for语句是比较灵活的循环控制语句,一个for语句的定义如下: - -```java -for(初始条件;循环条件;更新){ - //循环执行的内容 -} -//循环结束后,继续执行 -``` - -* 初始条件:循环开始时的条件,一般用于定义控制循环的变量。 -* 循环条件:每轮循环开始之前,进行一次判断,如果满足则继续,不满足则结束,要求为boolean变量或是boolean表达式。 -* 更新:每轮循环结束后都会执行的内容,一般写增量表达式。 - -初始条件、循环条件、更新条件不是缺一不可,甚至可以都缺! - -```java -for(int i = 0;i < 520;i++){ - System.out.println("我爱你"); -} -``` - -```java -for(;;){ - //这里的内容将会永远地进行下去! -} -``` - -增强for循环在数组时再讲解! - -#### while循环 - -while循环和for循环类似,但是它更加的简单,只需要添加维持循环的判断条件即可! - -```java -while(循环条件){ - //循环执行的内容 -} -``` - -和for一样,每次循环开始,当循环条件不满足时,自动退出!那么有时候我们希望先执行了我们的代码再去判断怎么办呢,我们可以使用do-while语句: - -```java -do{ - //执行内容 -}while(循环条件); -``` - -一定会先执行do里面的内容,再做判断! - -思考: - -```java -for(;;){ - -} - -while(true){ - -} - -//它们的性能谁更高? -``` - -*** - -## 面向过程编程实战(基础+算法) - -### 打印九九乘法表 - -简单:将九九乘法表打印到控制台。 - -### 求1000以内的水仙花数 - -中等:打印1000以内所有满足水仙花的数,“水仙花数”是指一个三位数其各位数字的立方和等于该数本身,例如153是“水仙花数”,因为:153 = 1^3 + 5^3 + 3^3 - -### 青蛙跳台阶问题 - -困难:一共有n个台阶,一只青蛙每次只能跳一阶或是两阶,那么一共有多少种跳到顶端的方案?例如n=2,那么一共有两种方案,一次性跳两阶或是每次跳一阶。 - -动态规划:其实,就是利用,上次得到的结果,给下一次作参考,下一次就能利用上次的结果快速得到结果,依次类推 - -*** - -不对啊,别的教程都讲了数组、方法,怎么我们还没讲就进入面向对象了呢? - -* 数组在Java中,并非基本类型,数组是编程不可见的对象类型,学习了面向对象再来理解,会更加容易! -* 方法在Java中是类具有的属性,所以,在了解了对象类型之后,再来了解方法,就更加简单了! diff --git a/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(七).md b/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(七).md deleted file mode 100644 index 527bdf2..0000000 --- a/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(七).md +++ /dev/null @@ -1,695 +0,0 @@ -# Java反射和注解 - -**注意:**本章节涉及到JVM相关底层原理,难度会有一些大。 - -反射就是把Java类中的各个成分映射成一个个的Java对象。即在运行状态中,对于任意一个类,都能够知道这个类所有的属性和方法,对于任意一个对象,都能调用它的任意一个方法和属性。这种动态获取信息及动态调用对象方法的功能叫Java的反射机制。 - -简而言之,我们可以通过反射机制,获取到类的一些属性,包括类里面有哪些字段,有哪些方法,继承自哪个类,甚至还能获取到泛型!它的权限非常高,慎重使用! - -## Java类加载机制 - -在学习Java的反射机制之前,我们需要先了解一下类的加载机制,一个类是如何被加载和使用的: - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg3.itboth.com%2F60%2F50%2FUrUVN3.png&refer=http%3A%2F%2Fimg3.itboth.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1637635409&t=f25ea82c853619c26897ff5b4d041d5b) - -在Java程序启动时,JVM会将一部分类(class文件)先加载(并不是所有的类都会在一开始加载),通过ClassLoader将类加载,在加载过程中,会将类的信息提取出来(存放在元空间中,JDK1.8之前存放在永久代),同时也会生成一个Class对象存放在内存(堆内存),注意此Class对象只会存在一个,与加载的类唯一对应! - -**思考:**既然说和与加载的类唯一对应,那如果我们手动创建一个与JDK包名一样,同时类名也保持一致,那么JVM会加载这个类吗? - -```java -package java.lang; - -public class String { //JDK提供的String类也是 - public static void main(String[] args) { - System.out.println("我姓🐴,我叫🐴nb"); - } -} -``` - -我们发现,会出现以下报错: - -```java -错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为: - public static void main(String[] args) -``` - -但是我们明明在自己写的String类中定义了main方法啊,为什么会找不到此方法呢?实际上这是ClassLoader的`双亲委派机制`在保护Java程序的正常运行: - -![img](https://img-blog.csdnimg.cn/20201217213314510.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2NvZGV5YW5iYW8=,size_16,color_FFFFFF,t_70) - -实际上我们的类最开始是由BootstarpClassLoader进行加载,BootstarpClassLoader用于加载JDK提供的类,而我们自己编写的类实际上是AppClassLoader,只有BootstarpClassLoader都没有加载的类,才会让AppClassLoader来加载,因此我们自己编写的同名包同名类不会被加载,而实际要去启动的是真正的String类,也就自然找不到`main`方法了! - -```java -public class Main { - public static void main(String[] args) { - System.out.println(Main.class.getClassLoader()); //查看当前类的类加载器 - System.out.println(Main.class.getClassLoader().getParent()); //父加载器 - System.out.println(Main.class.getClassLoader().getParent().getParent()); //爷爷加载器 - System.out.println(String.class.getClassLoader()); //String类的加载器 - } -} -``` - -由于BootstarpClassLoader是C++编写的,我们在Java中是获取不到的。 - -## Class对象 - -通过前面,我们了解了类的加载,同时会提取一个类的信息生成Class对象存放在内存中,而反射机制其实就是利用这些存放的类信息,来获取类的信息和操作类。那么如何获取到每个类对应的Class对象呢,我们可以通过以下方式: - -```java -public static void main(String[] args) throws ClassNotFoundException { - Class clazz = String.class; //使用class关键字,通过类名获取 - Class clazz2 = Class.forName("java.lang.String"); //使用Class类静态方法forName(),通过包名.类名获取,注意返回值是Class - Class clazz3 = new String("cpdd").getClass(); //通过实例对象获取 -} -``` - -注意Class类也是一个泛型类,只有第一种方法,能够直接获取到对应类型的Class对象,而以下两种方法使用了`?`通配符作为返回值,但是实际上都和第一个返回的是同一个对象: - -```java -Class clazz = String.class; //使用class关键字,通过类名获取 -Class clazz2 = Class.forName("java.lang.String"); //使用Class类静态方法forName(),通过包名.类名获取,注意返回值是Class -Class clazz3 = new String("cpdd").getClass(); - -System.out.println(clazz == clazz2); -System.out.println(clazz == clazz3); -``` - -通过比较,验证了我们一开始的结论,在JVM中每个类始终只存在一个Class对象,无论通过什么方法获取,都是一样的。现在我们再来看看这个问题: - -```java -public static void main(String[] args) { - Class clazz = int.class; //基本数据类型有Class对象吗? - System.out.println(clazz); -} -``` - -迷了,不是每个类才有Class对象吗,基本数据类型又不是类,这也行吗?实际上,基本数据类型也有对应的Class对象(反射操作可能需要用到),而且我们不仅可以通过class关键字获取,其实本质上是定义在对应的包装类中的: - -```java -/** - * The {@code Class} instance representing the primitive type - * {@code int}. - * - * @since JDK1.1 - */ -@SuppressWarnings("unchecked") -public static final Class TYPE = (Class) Class.getPrimitiveClass("int"); - -/* - * Return the Virtual Machine's Class object for the named - * primitive type - */ -static native Class getPrimitiveClass(String name); //C++实现,并非Java定义 -``` - -每个包装类中(包括Void),都有一个获取原始类型Class方法,注意,getPrimitiveClass获取的是原始类型,并不是包装类型,只是可以使用包装类来表示。 - -```java -public static void main(String[] args) { - Class clazz = int.class; - System.out.println(Integer.TYPE == int.class); -} -``` - -通过对比,我们发现实际上包装类型都有一个TYPE,其实也就是基本类型的Class,那么包装类的Class和基本类的Class一样吗? - -```java -public static void main(String[] args) { - System.out.println(Integer.TYPE == Integer.class); -} -``` - -我们发现,包装类型的Class对象并不是基本类型Class对象。数组类型也是一种类型,只是编程不可见,因此我们可以直接获取数组的Class对象: - -```java -public static void main(String[] args) { - Class clazz = String[].class; - System.out.println(clazz.getName()); //获取类名称(得到的是包名+类名的完整名称) - System.out.println(clazz.getSimpleName()); - System.out.println(clazz.getTypeName()); - System.out.println(clazz.getClassLoader()); //获取它的类加载器 - System.out.println(clazz.cast(new Integer("10"))); //强制类型转换 -} -``` - -### 再谈instanceof - -正常情况下,我们使用instanceof进行类型比较: - -```java -public static void main(String[] args) { - String str = ""; - System.out.println(str instanceof String); -} -``` - -它可以判断一个对象是否为此接口或是类的实现或是子类,而现在我们有了更多的方式去判断类型: - -```java -public static void main(String[] args) { - String str = ""; - System.out.println(str.getClass() == String.class); //直接判断是否为这个类型 -} -``` - -如果需要判断是否为子类或是接口/抽象类的实现,我们可以使用`asSubClass()`方法: - -```java -public static void main(String[] args) { - Integer i = 10; - i.getClass().asSubclass(Number.class); //当Integer不是Number的子类时,会产生异常 -} -``` - -### 获取父类信息 - -通过`getSuperclass()`方法,我们可以获取到父类的Class对象: - -```java -public static void main(String[] args) { - Integer i = 10; - System.out.println(i.getClass().getSuperclass()); -} -``` - -也可以通过`getGenericSuperclass()`获取父类的原始类型的Type: - -```java -public static void main(String[] args) { - Integer i = 10; - Type type = i.getClass().getGenericSuperclass(); - System.out.println(type); - System.out.println(type instanceof Class); -} -``` - -我们发现Type实际上是Class类的父接口,但是获取到的Type的实现并不一定是Class。 - -同理,我们也可以像上面这样获取父接口: - -```java -public static void main(String[] args) { - Integer i = 10; - for (Class anInterface : i.getClass().getInterfaces()) { - System.out.println(anInterface.getName()); - } - - for (Type genericInterface : i.getClass().getGenericInterfaces()) { - System.out.println(genericInterface.getTypeName()); - } -} -``` - -*** - -## 创建类对象 - -既然我们拿到了类的定义,那么是否可以通过Class对象来创建对象、调用方法、修改变量呢?当然是可以的,那我们首先来探讨一下如何创建一个类的对象: - -```java -public static void main(String[] args) throws InstantiationException, IllegalAccessException { - Class clazz = Student.class; - Student student = clazz.newInstance(); - student.test(); -} - -static class Student{ - public void test(){ - System.out.println("萨日朗"); - } -} -``` - -通过使用`newInstance()`方法来创建对应类型的实例,返回泛型T,注意它会抛出InstantiationException和IllegalAccessException异常,那么什么情况下会出现异常呢? - -```java -public static void main(String[] args) throws InstantiationException, IllegalAccessException { - Class clazz = Student.class; - Student student = clazz.newInstance(); - student.test(); -} - -static class Student{ - - public Student(String text){ - - } - - public void test(){ - System.out.println("萨日朗"); - } -} -``` - -当类默认的构造方法被带参构造覆盖时,会出现InstantiationException异常,因为`newInstance()`只适用于默认无参构造。 - -```java -public static void main(String[] args) throws InstantiationException, IllegalAccessException { - Class clazz = Student.class; - Student student = clazz.newInstance(); - student.test(); -} - -static class Student{ - - private Student(){} - - public void test(){ - System.out.println("萨日朗"); - } -} -``` - -当默认无参构造的权限不是`public`时,会出现IllegalAccessException异常,表示我们无权去调用默认构造方法。在JDK9之后,不再推荐使用`newInstance()`方法了,而是使用我们接下来要介绍到的,通过获取构造器,来实例化对象: - -```java -public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { - Class clazz = Student.class; - Student student = clazz.getConstructor(String.class).newInstance("what's up"); - student.test(); -} - -static class Student{ - - public Student(String str){} - - public void test(){ - System.out.println("萨日朗"); - } -} -``` - -通过获取类的构造方法(构造器)来创建对象实例,会更加合理,我们可以使用`getConstructor()`方法来获取类的构造方法,同时我们需要向其中填入参数,也就是构造方法需要的类型,当然我们这里只演示了。那么,当访问权限不是public的时候呢? - -```java -public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { - Class clazz = Student.class; - Student student = clazz.getConstructor(String.class).newInstance("what's up"); - student.test(); -} - -static class Student{ - - private Student(String str){} - - public void test(){ - System.out.println("萨日朗"); - } -} -``` - -我们发现,当访问权限不足时,会无法找到此构造方法,那么如何找到非public的构造方法呢? - -```java -Class clazz = Student.class; -Constructor constructor = clazz.getDeclaredConstructor(String.class); -constructor.setAccessible(true); //修改访问权限 -Student student = constructor.newInstance("what's up"); -student.test(); -``` - -使用`getDeclaredConstructor()`方法可以找到类中的非public构造方法,但是在使用之前,我们需要先修改访问权限,在修改访问权限之后,就可以使用非public方法了(这意味着,反射可以无视权限修饰符访问类的内容) - -*** - -## 调用类的方法 - -我们可以通过反射来调用类的方法(本质上还是类的实例进行调用)只是利用反射机制实现了方法的调用,我们在包下创建一个新的类: - -```java -package com.test; - -public class Student { - public void test(String str){ - System.out.println("萨日朗"+str); - } -} -``` - -这次我们通过`forName(String)`来找到这个类并创建一个新的对象: - -```java -public static void main(String[] args) throws ReflectiveOperationException { - Class clazz = Class.forName("com.test.Student"); - Object instance = clazz.newInstance(); //创建出学生对象 - Method method = clazz.getMethod("test", String.class); //通过方法名和形参类型获取类中的方法 - - method.invoke(instance, "what's up"); //通过Method对象的invoke方法来调用方法 -} -``` - -通过调用`getMethod()`方法,我们可以获取到类中所有声明为public的方法,得到一个Method对象,我们可以通过Method对象的`invoke()`方法(返回值就是方法的返回值,因为这里是void,返回值为null)来调用已经获取到的方法,注意传参。 - -我们发现,利用反射之后,在一个对象从构造到方法调用,没有任何一处需要引用到对象的实际类型,我们也没有导入Student类,整个过程都是反射在代替进行操作,使得整个过程被模糊了,过多的使用反射,会极大地降低后期维护性。 - -同构造方法一样,当出现非public方法时,我们可以通过反射来无视权限修饰符,获取非public方法并调用,现在我们将`test()`方法的权限修饰符改为private: - -```java -public static void main(String[] args) throws ReflectiveOperationException { - Class clazz = Class.forName("com.test.Student"); - Object instance = clazz.newInstance(); //创建出学生对象 - Method method = clazz.getDeclaredMethod("test", String.class); //通过方法名和形参类型获取类中的方法 - method.setAccessible(true); - - method.invoke(instance, "what's up"); //通过Method对象的invoke方法来调用方法 -} -``` - -Method和Constructor都和Class一样,他们存储了方法的信息,包括方法的形式参数列表,返回值,方法的名称等内容,我们可以直接通过Method对象来获取这些信息: - -```java -public static void main(String[] args) throws ReflectiveOperationException { - Class clazz = Class.forName("com.test.Student"); - Method method = clazz.getDeclaredMethod("test", String.class); //通过方法名和形参类型获取类中的方法 - - System.out.println(method.getName()); //获取方法名称 - System.out.println(method.getReturnType()); //获取返回值类型 -} -``` - -当方法的参数为可变参数时,我们该如何获取方法呢?实际上,我们在之前就已经提到过,可变参数实际上就是一个数组,因此我们可以直接使用数组的class对象表示: - -```java -Method method = clazz.getDeclaredMethod("test", String[].class); -``` - -反射非常强大,尤其是我们提到的越权访问,但是请一定谨慎使用,别人将某个方法设置为private一定有他的理由,如果实在是需要使用别人定义为private的方法,就必须确保这样做是安全的,在没有了解别人代码的整个过程就强行越权访问,可能会出现无法预知的错误。 - -*** - -## 修改类的属性 - -我们还可以通过反射访问一个类中定义的成员字段也可以修改一个类的对象中的成员字段值,通过`getField()`方法来获取一个类定义的指定字段: - -```java -public static void main(String[] args) throws ReflectiveOperationException { - Class clazz = Class.forName("com.test.Student"); - Object instance = clazz.newInstance(); - - Field field = clazz.getField("i"); //获取类的成员字段i - field.set(instance, 100); //将类实例instance的成员字段i设置为100 - - Method method = clazz.getMethod("test"); - method.invoke(instance); -} -``` - -在得到Field之后,我们就可以直接通过`set()`方法为某个对象,设定此属性的值,比如上面,我们就为instance对象设定值为100,当访问private字段时,同样可以按照上面的操作进行越权访问: - -```java -public static void main(String[] args) throws ReflectiveOperationException { - Class clazz = Class.forName("com.test.Student"); - Object instance = clazz.newInstance(); - - Field field = clazz.getDeclaredField("i"); //获取类的成员字段i - field.setAccessible(true); - field.set(instance, 100); //将类实例instance的成员字段i设置为100 - - Method method = clazz.getMethod("test"); - method.invoke(instance); -} -``` - -现在我们已经知道,反射几乎可以把一个类的老底都给扒出来,任何属性,任何内容,都可以被反射修改,无论权限修饰符是什么,那么,如果我的字段被标记为final呢?现在在字段`i`前面添加`final`关键字,我们再来看看效果: - -```java -private final int i = 10; -``` - -这时,当字段为final时,就修改失败了!当然,通过反射可以直接将final修饰符直接去除,去除后,就可以随意修改内容了,我们来尝试修改Integer的value值: - -```java -public static void main(String[] args) throws ReflectiveOperationException { - Integer i = 10; - - Field field = Integer.class.getDeclaredField("value"); - - Field modifiersField = Field.class.getDeclaredField("modifiers"); //这里要获取Field类的modifiers字段进行修改 - modifiersField.setAccessible(true); - modifiersField.setInt(field,field.getModifiers()&~Modifier.FINAL); //去除final标记 - - field.setAccessible(true); - field.set(i, 100); //强行设置值 - - System.out.println(i); -} -``` - -我们可以发现,反射非常暴力,就连被定义为final字段的值都能强行修改,几乎能够无视一切阻拦。我们来试试看修改一些其他的类型: - -```java -public static void main(String[] args) throws ReflectiveOperationException { - List i = new ArrayList<>(); - - Field field = ArrayList.class.getDeclaredField("size"); - field.setAccessible(true); - field.set(i, 10); - - i.add("测试"); //只添加一个元素 - System.out.println(i.size()); //大小直接变成11 - i.remove(10); //瞎移除都不带报错的,淦 -} -``` - -实际上,整个ArrayList体系由于我们的反射操作,导致被破坏,因此它已经无法正常工作了! - -再次强调,在进行反射操作时,必须注意是否安全,虽然拥有了创世主的能力,但是我们不能滥用,我们只能把它当做一个不得已才去使用的工具! - -*** - -## 自定义ClassLoader加载类 - -我们可以自己手动将class文件加载到JVM中吗?先写好我们定义的类: - -```java -package com.test; - -public class Test { - public String text; - - public void test(String str){ - System.out.println(text+" > 我是测试方法!"+str); - } -} -``` - -通过javac命令,手动编译一个.class文件: - -```shell -nagocoler@NagodeMacBook-Pro HelloWorld % javac src/main/java/com/test/Test.java -``` - -编译后,得到一个class文件,我们把它放到根目录下,然后编写一个我们自己的ClassLoader,因为普通的ClassLoader无法加载二进制文件,因此我们编写一个自己的来让它支持: - -```java -//定义一个自己的ClassLoader -static class MyClassLoader extends ClassLoader{ - public Class defineClass(String name, byte[] b){ - return defineClass(name, b, 0, b.length); //调用protected方法,支持载入外部class文件 - } -} - -public static void main(String[] args) throws IOException { - MyClassLoader classLoader = new MyClassLoader(); - FileInputStream stream = new FileInputStream("Test.class"); - byte[] bytes = new byte[stream.available()]; - stream.read(bytes); - Class clazz = classLoader.defineClass("com.test.Test", bytes); //类名必须和我们定义的保持一致 - System.out.println(clazz.getName()); //成功加载外部class文件 -} -``` - -现在,我们就将此class文件读取并解析为Class了,现在我们就可以对此类进行操作了(注意,我们无法在代码中直接使用此类型,因为它是我们直接加载的),我们来试试看创建一个此类的对象并调用其方法: - -```java -try { - Object obj = clazz.newInstance(); - Method method = clazz.getMethod("test", String.class); //获取我们定义的test(String str)方法 - method.invoke(obj, "哥们这瓜多少钱一斤?"); -}catch (Exception e){ - e.printStackTrace(); -} -``` - -我们来试试看修改成员字段之后,再来调用此方法: - -```java -try { - Object obj = clazz.newInstance(); - Field field = clazz.getField("text"); //获取成员变量 String text; - field.set(obj, "华强"); - Method method = clazz.getMethod("test", String.class); //获取我们定义的test(String str)方法 - method.invoke(obj, "哥们这瓜多少钱一斤?"); -}catch (Exception e){ - e.printStackTrace(); -} -``` - -通过这种方式,我们就可以实现外部加载甚至是网络加载一个类,只需要把类文件传递即可,这样就无需再将代码写在本地,而是动态进行传递,不仅可以一定程度上防止源代码被反编译(只是一定程度上,想破解你代码有的是方法),而且在更多情况下,我们还可以对byte[]进行加密,保证在传输过程中的安全性。 - -*** - -## 注解 - -其实我们在之前就接触到注解了,比如`@Override`表示重写父类方法(当然不加效果也是一样的,此注解在编译时会被自动丢弃)注解本质上也是一个类,只不过它的用法比较特殊。 - -注解可以被标注在任意地方,包括方法上、类名上、参数上、成员属性上、注解定义上等,就像注释一样,它相当于我们对某样东西的一个标记。而与注释不同的是,注解可以通过反射在运行时获取,注解也可以选择是否保留到运行时。 - -### 预设注解 - -JDK预设了以下注解,作用于代码: - -- @Override - 检查(仅仅是检查,不保留到运行时)该方法是否是重写方法。如果发现其父类,或者是引用的接口中并没有该方法时,会报编译错误。 -- @Deprecated - 标记过时方法。如果使用该方法,会报编译警告。 -- @SuppressWarnings - 指示编译器去忽略注解中声明的警告(仅仅编译器阶段,不保留到运行时) -- @FunctionalInterface - Java 8 开始支持,标识一个匿名函数或函数式接口。 -- @SafeVarargs - Java 7 开始支持,忽略任何使用参数为泛型变量的方法或构造函数调用产生的警告。 - -### 元注解 - -元注解是作用于注解上的注解,用于我们编写自定义的注解: - -- @Retention - 标识这个注解怎么保存,是只在代码中,还是编入class文件中,或者是在运行时可以通过反射访问。 -- @Documented - 标记这些注解是否包含在用户文档中。 -- @Target - 标记这个注解应该是哪种 Java 成员。 -- @Inherited - 标记这个注解是继承于哪个注解类(默认 注解并没有继承于任何子类) -- @Repeatable - Java 8 开始支持,标识某注解可以在同一个声明上使用多次。 - -看了这么多预设的注解,你们肯定眼花缭乱了,那我们来看看`@Override`是如何定义的: - -```java -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.SOURCE) -public @interface Override { -} -``` - -该注解由`@Target`限定为只能作用于方法上,ElementType是一个枚举类型,用于表示此枚举的作用域,一个注解可以有很多个作用域。`@Retention`表示此注解的保留策略,包括三种策略,在上述中有写到,而这里定义为只在代码中。一般情况下,自定义的注解需要定义1个`@Retention`和1-n个`@Target`。 - -既然了解了元注解的使用和注解的定义方式,我们就来尝试定义一个自己的注解: - -```java -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface Test { -} -``` - -这里我们定义一个Test注解,并将其保留到运行时,同时此注解可以作用于方法或是类上: - -```java -@Test -public class Main { - @Test - public static void main(String[] args) { - - } -} -``` - -这样,一个最简单的注解就被我们创建了。 - -### 注解的使用 - -我们还可以在注解中定义一些属性,注解的属性也叫做成员变量,注解只有成员变量,没有方法。注解的成员变量在注解的定义中以“无形参的方法”形式来声明,其方法名定义了该成员变量的名字,其返回值定义了该成员变量的类型: - -```java -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -public @interface Test { - String value(); -} -``` - -默认只有一个属性时,我们可以将其名字设定为value,否则,我们需要在使用时手动指定注解的属性名称,使用value则无需填入: - -```java -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -public @interface Test { - String test(); -} -``` - -```java -public class Main { - @Test(test = "") - public static void main(String[] args) { - - } -} -``` - -我们也可以使用default关键字来为这些属性指定默认值: - -```java -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -public @interface Test { - String value() default "都看到这里了,给个三连吧!"; -} -``` - -当属性存在默认值时,使用注解的时候可以不用传入属性值。当属性为数组时呢? - -```java -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -public @interface Test { - String[] value(); -} -``` - -当属性为数组,我们在使用注解传参时,如果数组里面只有一个内容,我们可以直接传入一个值,而不是创建一个数组: - -```java -@Test("关注点了吗") -public static void main(String[] args) { - -} -``` - -```java -public class Main { - @Test({"value1", "value2"}) //多个值时就使用花括号括起来 - public static void main(String[] args) { - - } -} -``` - -### 反射获取注解 - -既然我们的注解可以保留到运行时,那么我们来看看,如何获取我们编写的注解,我们需要用到反射机制: - -```java -public static void main(String[] args) { - Class clazz = Student.class; - for (Annotation annotation : clazz.getAnnotations()) { - System.out.println(annotation.annotationType()); //获取类型 - System.out.println(annotation instanceof Test); //直接判断是否为Test - Test test = (Test) annotation; - System.out.println(test.value()); //获取我们在注解中写入的内容 - } -} -``` - -通过反射机制,我们可以快速获取到我们标记的注解,同时还能获取到注解中填入的值,那么我们来看看,方法上的标记是不是也可以通过这种方式获取注解: - -```java -public static void main(String[] args) throws NoSuchMethodException { - Class clazz = Student.class; - for (Annotation annotation : clazz.getMethod("test").getAnnotations()) { - System.out.println(annotation.annotationType()); //获取类型 - System.out.println(annotation instanceof Test); //直接判断是否为Test - Test test = (Test) annotation; - System.out.println(test.value()); //获取我们在注解中写入的内容 - } -} -``` - -无论是方法、类、还是字段,都可以使用`getAnnotations()`方法(还有几个同名的)来快速获取我们标记的注解。 - -所以说呢,这玩意学来有啥用?丝毫get不到这玩意的用处。其实不是,现阶段你们还体会不到注解带来的快乐,在接触到Spring和SpringBoot等大型框架后,就能感受到注解带来的魅力了。 - diff --git a/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(三).md b/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(三).md deleted file mode 100644 index e0e8fc5..0000000 --- a/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(三).md +++ /dev/null @@ -1,306 +0,0 @@ -# Java异常机制 - -在理想的情况下,我们的程序会按照我们的思路去运行,按理说是不会出现问题的,但是,代码实际编写后并不一定是完美的,可能会有我们没有考虑到的情况,如果这些情况能够正常得到一个错误的结果还好,但是如果直接导致程序运行出现问题了呢? - -```java -public static void main(String[] args) { - test(1, 0); //当b为0的时候,还能正常运行吗? -} - -private static int test(int a, int b){ - return a/b; //没有任何的判断而是直接做计算 -} - -Exception in thread "main" java.lang.ArithmeticException: / by zero - at com.test.Application.test(Application.java:9) - at com.test.Application.main(Application.java:5) -``` - -当程序运行出现我们没有考虑到的情况时,就有可能出现异常或是错误! - -## 异常 - -我们在之前其实已经接触过一些异常了,比如数组越界异常,空指针异常,算术异常等,他们其实都是异常类型,我们的每一个异常也是一个类,他们都继承自`Exception`类!异常类型本质依然类的对象,但是异常类型支持在程序运行出现问题时抛出(也就是上面出现的红色报错)也可以提前声明,告知使用者需要处理可能会出现的异常! - -### 运行时异常 - -异常的第一种类型是运行时异常,如上述的列子,在编译阶段无法感知代码是否会出现问题,只有在运行的时候才知道会不会出错(正常情况下是不会出错的),这样的异常称为运行时异常。所有的运行时异常都继承自`RuntimeException`。 - -### 编译时异常 - -异常的另一种类型是编译时异常,编译时异常是明确会出现的异常,在编译阶段就需要进行处理的异常(捕获异常)如果不进行处理,将无法通过编译!默认继承自`Exception`类的异常都是编译时异常。 - -```java -File file = new File("my.txt"); -file.createNewFile(); //要调用此方法,首先需要处理异常 -``` - -## 错误 - -错误比异常更严重,异常就是不同寻常,但不一定会导致致命的问题,而错误是致命问题,一般出现错误可能JVM就无法继续正常运行了,比如`OutOfMemoryError`就是内存溢出错误(内存占用已经超出限制,无法继续申请内存了) - -```java -int[] arr = new int[Integer.MAX_VALUE]; //能创建如此之大的数组吗? -``` - -运行后得到以下内容: - -```java -Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit - at com.test.Main.main(Main.java:14) -``` - -错误都继承自`Error`类,一般情况下,程序中只能处理异常,错误是很难进行处理的,`Error`和`Execption`都继承自`Throwable`类。当程序中出现错误或异常时又没有进行处理时,程序(当前线程)将终止运行: - -```java -int[] arr = new int[Integer.MAX_VALUE]; -System.out.println("lbwnb"); //还能正常打印吗? -``` - -## 异常的处理 - -当程序没有按照我们想要的样子运行而出现异常时(默认会交给JVM来处理,JVM发现任何异常都会立即终止程序运行,并在控制台打印栈追踪信息),我们希望能够自己处理出现的问题,让程序继续运行下去,就需要对异常进行捕获,比如: - -```java -int[] arr = new int[5]; -arr[5] = 1; //我们需要处理这种情况,保证后面的代码正常运行! -System.out.println("lbwnb"); -``` - -我们可以使用`try`和`catch`语句块来处理: - -```java -int[] arr = new int[5]; -try{ //在try块中运行代码 - arr[5] = 1; //当代码出现异常时,异常会被捕获,并在catch块中得到异常类型的对象 -}catch (ArrayIndexOutOfBoundsException e){ //捕获的异常类型 - System.out.println("程序运行出现异常!"); //出现异常时执行 -} -//后面的代码会正常运行 -System.out.println("lbwnb"); -``` - -当异常被捕获后,就由我们自己进行处理(不再交给JVM处理),因此就不会导致程序终止运行。 - -我们可以通过使用`e.printStackTrace()`来打印栈追踪信息,定位我们的异常出现位置: - -```java -java.lang.ArrayIndexOutOfBoundsException: 5 - at com.test.Main.main(Main.java:7) //Main类的第7行出现问题 -程序运行出现异常! -lbwnb -``` - -运行时异常在编译时可以不用捕获,但是编译时异常必须进行处理: - -```java -File file = new File("my.txt"); -try { - file.createNewFile(); -} catch (IOException e) { //捕获声明的异常类型 - e.printStackTrace(); -} -``` - -可以捕获到类型不止是`Exception`的子类,只要是继承自`Throwalbe`的类,都能被捕获,也就是说,`Error`也能被捕获,但是不建议这样做,因为错误一般是虚拟机相关的问题,出现`Error`应该从问题的根源去解决。 - -## 异常的抛出 - -当别人调用我们的方法时,如果传入了错误的参数导致程序无法正常运行,这时我们就需要手动抛出一个异常来终止程序继续运行下去,同时告知上一级方法执行出现了问题: - -```java -public static void main(String[] args) { - try { - test(1, 0); - } catch (Exception e) { //捕获方法中会出现的异常 - e.printStackTrace(); - } - } - - private static int test(int a, int b) throws Exception { //声明抛出的异常类型 - if(b == 0) throw new Exception("0不能做除数!"); //创建异常对象并抛出异常 - return a/b; //抛出异常会终止代码运行 - } -``` - -通过`throw`关键字抛出异常(抛出异常后,后面的代码不再执行)当程序运行到这一行时,就会终止执行,并出现一个异常。 - -如果方法中抛出了非运行时异常,但是不希望在此方法内处理,而是交给调用者来处理异常,就需要在方法定义后面显式声明抛出的异常类型!如果抛出的是运行时异常,则不需要在方法后面声明异常类型,调用时也无需捕获,但是出现异常时同样会导致程序终止(出现运行时异常同时未被捕获会默认交给JVM处理,也就是直接中止程序并在控制台打印栈追踪信息) - -如果想要调用声明编译时异常的方法,但是依然不想去处理,可以同样的在方法上声明`throws`来继续交给上一级处理。 - -```java -public static void main(String[] args) throws Exception { //出现异常就再往上抛,而不是在此方法内处理 - test(1, 0); -} - -private static int test(int a, int b) throws Exception { //声明抛出的异常类型 - if(b == 0) throw new Exception("0不能做除数!"); //创建异常对象并抛出异常 - return a/b; -} -``` - -当main方法都声明抛出异常时,出现异常就由JVM进行处理,也就是默认的处理方式(直接中止程序并在控制台打印栈追踪信息) - -异常只能被捕获一次,当异常捕获出现嵌套时,只会在最内层被捕获: - -```java -public static void main(String[] args) throws Exception { - try{ - test(1, 0); - }catch (Exception e){ - System.out.println("外层"); - } - } - - private static int test(int a, int b){ - try{ - if(b == 0) throw new Exception("0不能做除数!"); - }catch (Exception e){ - System.out.println("内层"); - return 0; - } - return a/b; - } -``` - -## 自定义异常 - -JDK为我们已经提前定义了一些异常了,但是可能对我们来说不够,那么就需要自定义异常: - -```java -public class MyException extends Exception { //直接继承即可 - -} - -public static void main(String[] args) throws MyException { - throw new MyException(); //直接使用 - } -``` - -也可以使用父类的带描述的构造方法: - -```java -public class MyException extends Exception { - - public MyException(String message){ - super(message); - } -} - -public static void main(String[] args) throws MyException { - throw new MyException("出现了自定义的错误"); -} -``` - -捕获异常指定的类型,会捕获其所有子异常类型: - -```java -try { - throw new MyException("出现了自定义的错误"); -} catch (Exception e) { //捕获父异常类型 - System.out.println("捕获到异常"); -} -``` - -## 多重异常捕获和finally关键字 - -当代码可能出现多种类型的异常时,我们希望能够分不同情况处理不同类型的异常,就可以使用多重异常捕获: - -```java -try { - //.... -} catch (NullPointerException e) { - -} catch (IndexOutOfBoundsException e){ - -} catch (RuntimeException e){ - -} -``` - -注意,类似于`if-else if`的结构,父异常类型只能放在最后! - -```java -try { - //.... -} catch (RuntimeException e){ //父类型在前,会将子类的也捕获 - -} catch (NullPointerException e) { //永远都不会被捕获 - -} catch (IndexOutOfBoundsException e){ //永远都不会被捕获 - -} -``` - -如果希望把这些异常放在一起进行处理: - -```java -try { - //.... -} catch (NullPointerException | IndexOutOfBoundsException e) { //用|隔开每种类型即可 - -} -``` - -当我们希望,程序运行时,无论是否出现异常,都会在最后执行的任务,可以交给`finally`语句块来处理: - -```java -try { - //.... -}catch (Exception e){ - -}finally { - System.out.println("lbwnb"); //无论是否出现异常,都会在最后执行 -} -``` - -`try`语句块至少要配合`catch`或`finally`中的一个: - -```java -try { - int a = 10; - a /= 0; -}finally { //不捕获异常,程序会终止,但在最后依然会执行下面的内容 - System.out.println("lbwnb"); -} -``` - -思考:`try`、`catch`和`finally`执行顺序: - -```java -private static int test(int a){ - try{ - return a; - }catch (Exception e){ - return 0; - }finally { - a = a + 1; - } -} -``` - -# Java泛型与集合类 - -## 泛型 - -## 利用代码块来快速添加内容 - -# Java BIO - -## try-with-resourse - -# Java 多线程 - -# Java反射 - -## 详谈类加载机制 - -# Java注解 - - - - - - - diff --git a/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(二).md b/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(二).md deleted file mode 100644 index 85b0482..0000000 --- a/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(二).md +++ /dev/null @@ -1,1393 +0,0 @@ -# Java对象和多态 (面向对象) - -## 面向对象基础 - -面向对象程序设计(Object Oriented Programming) - -对象基于类创建,类相当于一个模板,对象就是根据模板创建出来的实体(就像做月饼,我们要做一个月饼首先需要一个模具,模具就是我们的类,而做出来的月饼,就是类的实现,也叫做对象),类是抽象的数据类型,并不能代表某一个具体的事物,类是对象的一个模板。类具有自己的属性,包括成员变量、成员方法等,我们可以调用类的成员方法来让类进行一些操作。 - -```java -Scanner sc = new Scanner(System.in); -String str = sc.nextLine(); -System.out.println("你输入了:"+str); -sc.close(); -``` - -所有的对象,都需要通过`new`关键字创建,基本数据类型不是对象!Java不是纯面对对象语言! - -不是基本类型的变量,都是引用类型,引用类型变量代表一个对象,而基本数据类型变量,保存的是基本数据类型的值,我们可以通过引用来对对象进行操作。(最好不要理解为引用指向对象的地址,初学者不要谈内存,学到JVM时再来讨论) - -对象占用的内存由JVM统一管理,不需要手动释放内存,当一个对象不再使用时(比如失去引用或是离开了作用域)会被JVM自动清理,内存管理更方便! - -*** - -## 类的基本结构 - -为了快速掌握,我们自己创建一个自己的类,创建的类文件名称应该和类名一致。 - -### 成员变量 - -在类中,可以包含许多的成员变量,也叫成员属性,成员字段(field)通过`.`来访问我们类中的成员变量,我们可以通过类创建的对象来访问和修改这些变量。成员变量是属于对象的! - -```java -public class Test { - int age; - String name; -} - -public static void main(String[] args) { - Test test = new Test(); - test.name = "奥利给"; - System.out.println(test.name); -} -``` - -成员变量默认带有初始值,也可以自己定义初始值。 - -### 成员方法 - -我们之前的学习中接触过方法(Method)吗?主方法! - -```java -public static void main(String[] args) { - //Body -} -``` - -方法是语句的集合,是为了完成某件事情而存在的。完成某件事情,可以有结果,也可以做了就做了,不返回结果。比如计算两个数字的和,我们需要得到计算后的结果,所以说方法需要有返回值;又比如,我们只想吧数字打印在控制台,只需要打印就行,不用给我结果,所以说方法不需要有返回值。 - -#### 方法的定义和使用 - -在类中,我们可以定义自己的方法,格式如下: - -```java -[返回值类型] 方法名称([参数]){ - //方法体 - return 结果; -} -``` - -* 返回值类型:可以是引用类型和基本类型,还可以是void,表示没有返回值 -* 方法名称:和标识符的规则一致,和变量一样,规范小写字母开头! -* 参数:例如方法需要计算两个数的和,那么我们就要把两个数到底是什么告诉方法,那么它们就可以作为参数传入方法 -* 方法体:方法具体要干的事情 -* 结果:方法执行的结果通过return返回(如果返回类型为void,可以省略return) - -非void方法中,`return`关键字不一定需要放在最后,但是一定要保证方法在任何情况下都具有返回值! - -```java -int test(int a){ - if(a > 0){ - //缺少retrun语句! - }else{ - return 0; - } -} -``` - -`return`也能用来提前结束整个方法,无论此时程序执行到何处,无论return位于哪里,都会立即结束个方法! - -```java -void main(String[] args) { - for (int i = 0; i < 10; i++) { - if(i == 1) return; //在循环内返回了!和break区别? - } - System.out.println("淦"); //还会到这里吗? -} -``` - -传入方法的参数,如果是基本类型,会在调用方法的时候,对参数的值进行复制,方法中的参数变量,不是我们传入的变量本身! - -```java -public static void main(String[] args) { - int a = 10, b = 20; - new Test().swap(a, b); - System.out.println("a="+a+", b="+b); -} - -public class Test{ - void swap(int a, int b){ //传递的仅仅是值而已! - int temp = a; - a = b; - b = temp; - } -} -``` - -传入方法的参数,如果是引用类型,那么传入的依然是该对象的引用!(类似于C语言的指针) - -```java -public class B{ - String name; -} - -public class A{ - void test(B b){ //传递的是对象的引用,而不是值 - System.out.println(b.name); - } -} - -public static void main(String[] args) { - int a = 10, b = 20; - B b = new B(); - b.name = "lbw"; - new A().test(b); - System.out.println("a="+a+", b="+b); -} -``` - -方法之间可以相互调用 - -```java -void a(){ - //xxxx -} - -void b(){ - a(); -} -``` - -当方法在自己内部调用自己时,称为递归调用(递归很危险,慎重!) - -```java -int a(){ - return a(); -} -``` - -成员方法和成员变量一样,是属于对象的,只能通过对象去调用! - -*** - -### 对象设计练习 - -* 学生应该具有以下属性:名字、年龄 -* 学生应该具有以下行为:学习、运动、说话 - -*** - -### 方法的重载 - -一个类中可以包含多个同名的方法,但是需要的形式参数不一样。(补充:形式参数就是定义方法需要的参数,实际参数就传入的参数)方法的返回类型,可以相同,也可以不同,但是仅返回类型不同,是不允许的! - -```java -public class Test { - int a(){ //原本的方法 - return 1; - } - - int a(int i){ //ok,形参不同 - return i; - } - - void a(byte i){ //ok,返回类型和形参都不同 - - } - - void a(){ //错误,仅返回值类型名称不同不能重载 - - } -} -``` - -现在我们就可以使用不同的参数,但是支持调用同样的方法,执行一样的逻辑: - -```java -public class Test { - int sum(int a, int b){ //只有int支持,不灵活! - return a+b; - } - - double sum(double a, double b){ //重写一个double类型的,就支持小数计算了 - return a+b; - } -} -``` - -现在我们有很多种重写的方法,那么传入实参后,到底进了哪个方法呢? - -```java -public class Test { - void a(int i){ - System.out.println("调用了int"); - } - - void a(short i){ - System.out.println("调用了short"); - } - - void a(long i){ - System.out.println("调用了long"); - } - - void a(char i){ - System.out.println("调用了char"); - } - - void a(double i){ - System.out.println("调用了double"); - } - - void a(float i){ - System.out.println("调用了float"); - } - - public static void main(String[] args) { - Test test = new Test(); - test.a(1); //直接输入整数 - test.a(1.0); //直接输入小数 - - short s = 2; - test.a(s); //会对号入座吗? - test.a(1.0F); - } -} -``` - -### 构造方法 - -构造方法(构造器)没有返回值,也可以理解为,返回的是当前对象的引用!每一个类都默认自带一个无参构造方法。 - -```java -//反编译结果 -package com.test; - -public class Test { - public Test() { //即使你什么都不编写,也自带一个无参构造方法,只是默认是隐藏的 - } -} -``` - -反编译其实就是把我们编译好的class文件变回Java源代码。 - -```java -Test test = new Test(); //实际上存在Test()这个的方法,new关键字就是用来创建并得到引用的 -// new + 你想要使用的构造方法 -``` - -这种方法没有写明返回值,但是每个类都必须具有这个方法!只有调用类的构造方法,才能创建类的对象! - -类要在一开始准备的所有东西,都会在构造方法里面执行,完成构造方法的内容后,才能创建出对象! - -一般最常用的就是给成员属性赋初始值: - -```java -public class Student { - String name; - - Student(){ - name = "伞兵一号"; - } -} -``` - -我们可以手动指定有参构造,当遇到名称冲突时,需要用到this关键字 - -```java -public class Student { - String name; - - Student(String name){ //形参和类成员变量冲突了,Java会优先使用形式参数定义的变量! - this.name = name; //通过this指代当前的对象属性,this就代表当前对象 - } -} - -//idea 右键快速生成! -``` - -注意,this只能用于指代当前对象的内容,因此,只有属于对象拥有的部分才可以使用this,也就是说,只能在类的成员方法中使用this,不能在静态方法中使用this关键字。 - -在我们定义了新的有参构造之后,默认的无参构造会被覆盖! - -```java -//反编译后依然只有我们定义的有参构造! -``` - -如果同时需要有参和无参构造,那么就需要用到方法的重载!手动再去定义一个无参构造。 - -```java -public class Student { - String name; - - Student(){ - - } - - Student(String name){ - this.name = name; - } -} -``` - -成员变量的初始化始终在构造方法执行之前 - -```java -public class Student { - String a = "sadasa"; - - Student(){ - System.out.println(a); - } - - public static void main(String[] args) { - Student s = new Student(); - } -} -``` - -### 静态变量和静态方法 - -静态变量和静态方法是类具有的属性(后面还会提到静态类、静态代码块),也可以理解为是所有对象共享的内容。我们通过使用`static`关键字来声明一个变量或一个方法为静态的,一旦被声明为静态,那么通过这个类创建的所有对象,操作的都是同一个目标,也就是说,对象再多,也只有这一个静态的变量或方法。那么,一个对象改变了静态变量的值,那么其他的对象读取的就是被改变的值。 - -```java -public class Student { - static int a; -} - -public static void main(String[] args) { - Student s1 = new Student(); - s1.a = 10; - Student s2 = new Student(); - System.out.println(s2.a); -} -``` - -不推荐使用对象来调用,被标记为静态的内容,可以直接通过`类名.xxx`的形式访问 - -```java -public static void main(String[] args) { - Student.a = 10; - System.out.println(Student.a); -} -``` - -#### 简述类加载机制 - -类并不是在一开始就全部加载好,而是在需要时才会去加载(提升速度)以下情况会加载类: - -- 访问类的静态变量,或者为静态变量赋值 -- new 创建类的实例(隐式加载) -- 调用类的静态方法 -- 子类初始化时 -- 其他的情况会在讲到反射时介绍 - -所有被标记为静态的内容,会在类刚加载的时候就分配,而不是在对象创建的时候分配,所以说静态内容一定会在第一个对象初始化之前完成加载。 - -```java -public class Student { - static int a = test(); //直接调用静态方法,只能调用静态方法 - - Student(){ - System.out.println("构造类对象"); - } - - static int test(){ //静态方法刚加载时就有了 - System.out.println("初始化变量a"); - return 1; - } -} -``` - -思考:下面这种情况下,程序能正常运行吗?如果能,会输出什么内容? - -```java -public class Student { - static int a = test(); - - static int test(){ - return a; - } - - public static void main(String[] args) { - System.out.println(Student.a); - } -} -``` - -定义和赋值是两个阶段,在定义时会使用默认值(上面讲的,类的成员变量会有默认值)定义出来之后,如果发现有赋值语句,再进行赋值,而这时,调用了静态方法,所以说会先去加载静态方法,静态方法调用时拿到a,而a这时仅仅是刚定义,所以说还是初始值,最后得到0 - -### 代码块和静态代码块 - -代码块在对象创建时执行,也是属于类的内容,但是它在构造方法执行之前执行(和成员变量初始值一样),且每创建一个对象时,只执行一次!(相当于构造之前的准备工作) - -```java -public class Student { - { - System.out.println("我是代码块"); - } - - Student(){ - System.out.println("我是构造方法"); - } -} -``` - -静态代码块和上面的静态方法和静态变量一样,在类刚加载时就会调用; - -```java -public class Student { - static int a; - - static { - a = 10; - } - - public static void main(String[] args) { - System.out.println(Student.a); - } -} -``` - -### String和StringBuilder类 - -字符串类是一个比较特殊的类,他是Java中唯一重载运算符的类!(Java不支持运算符重载,String是特例) - -String的对象直接支持使用`+`或`+=`运算符来进行拼接,并形成新的String对象!(String的字符串是不可变的!) - -```java -String a = "dasdsa", b = "dasdasdsa"; -String l = a+b; -System.out.println(l); -``` - -大量进行字符串的拼接似乎不太好,编译器是很聪明的,String的拼接有可能会被编译器优化为StringBuilder来减少对象创建(对象频繁创建时很费时间同时占内存的!) - -```java -String result="String"+"and"; //会被优化成一句! -``` - -```java -String str1="String"; -String str2="and"; -String result=str1+str2; -//变量随时可变,在编译时无法确定result的值,那么只能在运行时再去确定 -``` - -```java -String str1="String"; -String str2="and"; -String result=(new StringBuilder(String.valueOf(str1))).append(str2).toString(); -//使用StringBuilder,会采用类似于第一种实现,显然会更快! -``` - -StringBuilder也是一个类,但是它能够存储可变长度的字符串! - -```java -StringBuilder builder = new StringBuilder(); -builder - .append("a") - .append("bc") - .append("d"); //链式调用 -String str = builder.toString(); -System.out.println(str); -``` - -*** - -## 包和访问控制 - -### 包声明和导入 - -包其实就是用来区分类位置的东西,也可以用来将我们的类进行分类,类似于C++中的namespace! - -```java -package com.test; - -public class Test{ - -} -``` - -包其实是文件夹,比如com.test就是一个com文件夹中包含一个test文件夹,再包含我们Test类。 - -一般包按照个人或是公司域名的规则倒过来写 `顶级域名.一级域名.二级域名` `com.java.xxxx` - -如果需要使用其他包里面的类,那么我们需要`import`(类似于C/C++中的include) - -```java -import com.test.Student; -``` - -也可以导入包下的全部(一般导入会由编译器自带帮我们补全,但是一定要记得我们需要导包!) - -```java -import com.test.* -``` - -Java默认为我们导入了以下的包,不需要去声明 - -```java -import java.lang.* -``` - -### 静态导入 - -静态导入可以直接导入某个类的静态方法或者是静态变量,导入后,相当于这个方法或是类在定义在当前类中,可以直接调用该方法。 - -```java -import static com.test.ui.Student.test; - -public class Main { - public static void main(String[] args) { - test(); - } -} -``` - -静态导入不会进行类的初始化! - -### 访问控制 - -Java支持对类属性访问的保护,也就是说,不希望外部类访问类中的属性或是方法,只允许内部调用,这种情况下我们就需要用到权限控制符。 - -![image-20210819160939950](/Users/nagocoler/Library/Application Support/typora-user-images/image-20210819160939950.png) - -权限控制符可以声明在方法、成员变量、类前面,一旦声明private,只能类内部访问! - -```java -public class Student { - private int a = 10; //具有私有访问权限,只能类内部访问 -} - -public static void main(String[] args) { - Student s = new Student(); - System.out.println(s.a); //还可以访问吗? -} -``` - -和文件名称相同的类,只能是public,并且一个java文件中只能有一个public class! - -```java -// Student.java -public class Student { - -} -class Test{ //不能添加权限修饰符!只能是default - -} -``` - -*** - -## 数组类型 - -假设出现一种情况,我想记录100个数字,定义100个变量还可行吗? - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimgs.itxueyuan.com%2FCgq2xl329g-Adz0uAACwgSFkMho326.png&refer=http%3A%2F%2Fimgs.itxueyuan.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1632192902&t=7a6d67fc01d0e3ea7816adf951c49605) - -我们可以使用到数组,数组是相同类型数据的有序集合。数组可以代表任何相同类型的一组内容(包括引用类型和基本类型)其中存放的每一个数据称为数组的一个元素,数组的下标是从0开始,也就是第一个元素的索引是0! - -```java -int[] arr = new int[10]; //需要new关键字来创建! -String[] arr2 = new String[10]; -``` - -数组本身也是类(编程不可见,C++写的),不是基本数据类型! - -```java -int[] arr = new int[10]; -System.out.println(arr.length); //数组有成员变量! -System.out.println(arr.toString()); //数组有成员方法! -``` - -### 一维数组 - -一维数组中,元素是依次排列的(线性),每个数组元素可以通过下标来访问!声明格式如下: - -```  java -类型[] 变量名称 = new 类型[数组大小]; -类型 变量名称n = new 类型[数组大小]; //支持C语言样式,但不推荐! - -类型[] 变量名称 = new 类型[]{...}; //静态初始化(直接指定值和大小) -类型[] 变量名称 = {...}; //同上,但是只能在定义时赋值 -``` - - 创建出来的数组每个元素都有默认值(规则和类的成员变量一样,C语言创建的数组需要手动设置默认值),我们可以通过下标去访问: - -```java -int[] arr = new int[10]; -arr[0] = 626; -System.out.println(arr[0]); -System.out.println(arr[1]); -``` - -我们可以通过`数组变量名称.length`来获取当前数组长度: - -```java -int[] arr = new int[]{1, 2, 3}; -System.out.println(arr.length); //打印length成员变量的值 -``` - -数组在创建时,就固定长度,不可更改!访问超出数组长度的内容,会出现错误! - -```java -String[] arr = new String[10]; -System.out.println(arr[10]); //出现异常! - -//Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 11 -// at com.test.Application.main(Application.java:7) -``` - -思考:能不能直接修改length的值来实现动态扩容呢? - -```java -int[] arr = new int[]{1, 2, 3}; -arr.length = 10; -``` - -数组做实参,因为数组也是类,所以形参得到的是数组的引用而不是复制的数组,操作的依然是数组对象本身 - -```java -public static void main(String[] args) { - int[] arr = new int[]{1, 2, 3}; - test(arr); - System.out.println(arr[0]); -} - -private static void test(int[] arr){ - arr[0] = 2934; -} -``` - -### 数组的遍历 - -如果我们想要快速打印数组中的每一个元素,又怎么办呢? - -#### 传统for循环 - -我们很容易就联想到for循环 - -```java -int[] arr = new int[]{1, 2, 3}; -for (int i = 0; i < arr.length; i++) { - System.out.println(arr[i]); -} -``` - -#### foreach - -传统for循环虽然可控性高,但是不够省事,要写一大堆东西,有没有一种省事的写法呢? - -```java -int[] arr = new int[]{1, 2, 3}; -for (int i : arr) { - System.out.println(i); -} -``` - -foreach属于增强型的for循环,它使得代码更简洁,同时我们能直接拿到数组中的每一个数字。 - -### 二维数组 - -二维数组其实就是存放数组的数组,每一个元素都存放一个数组的引用,也就相当于变成了一个平面。 - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Ffile.elecfans.com%2Fweb1%2FM00%2FDD%2F01%2Fo4YBAGASjymAK8QIAADiOdWkSVA342.jpg&refer=http%3A%2F%2Ffile.elecfans.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1632204192&t=52381354d190d09899776f9bb868ef3e) - -```java -//三行两列 -int[][] arr = { {1, 2}, - {3, 4}, - {5, 6}}; -System.out.println(arr[2][1]); -``` - -二维数组的遍历同一维数组一样,只不过需要嵌套循环! - -```java -int[][] arr = new int[][]{ {1, 2}, - {3, 4}, - {5, 6}}; -for (int i = 0; i < 3; i++) { - for (int j = 0; j < 2; j++) { - System.out.println(arr[i][j]); - } -} -``` - -### 多维数组 - -不止二维数组,还存在三维数组,也就是存放数组的数组的数组,原理同二维数组一样,逐级访问即可。 - -### 可变长参数 - -可变长参数其实就是数组的一种应用,我们可以指定方法的形参为一个可变长参数,要求实参可以根据情况动态填入0个或多个,而不是固定的数量 - -```java -public static void main(String[] args) { - test("AAA", "BBB", "CCC"); //可变长,最后都会被自动封装成一个数组 -} - -private static void test(String... test){ - System.out.println(test[0]); //其实参数就是一个数组 -} -``` - -由于是数组,所以说只能使用一种类型的可变长参数,并且可变长参数只能放在最后一位! - -### 实战:三大基本排序算法 - -现在我们有一个数组,但是数组里面的数据是乱序排列的,如何使它变得有序? - -```java -int[] arr = {8, 5, 0, 1, 4, 9, 2, 3, 6, 7}; -``` - -排序是编程的一个重要技能,掌握排序算法,你的技术才能更上一层楼,很多的项目都需要用到排序!三大排序算法: - -* 冒泡排序 - -冒泡排序就是冒泡,其实就是不断使得我们无序数组中的最大数向前移动,经历n轮循环逐渐将每一个数推向最前。 - -* 插入排序 - -插入排序其实就跟我们打牌是一样的,我们在摸牌的时候,牌堆是乱序的,但是我们一张一张摸到手中进行排序,使得它变成了有序的! - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg1.jjhgame.com%2Fstatic_data%2Fnewshelp%2F113_5c08e82d2ac8b.jpg&refer=http%3A%2F%2Fimg1.jjhgame.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1632208529&t=f3fb9be4dce91c6364f5ec4f9faafc94) - -* 选择排序 - -选择排序其实就是每次都选择当前数组中最大的数排到最前面! - -*** - -## 封装、继承和多态 - -封装、继承和多态是面向对象编程的三大特性。 - -### 封装 - -封装的目的是为了保证变量的安全性,使用者不必在意具体实现细节,而只是通过外部接口即可访问类的成员,如果不进行封装,类中的实例变量可以直接查看和修改,可能给整个代码带来不好的影响,因此在编写类时一般将成员变量私有化,外部类需要同getter和setter方法来查看和设置变量。 - -设想:学生小明已经创建成功,正常情况下能随便改他的名字和年龄吗? - -```java -public class Student { - private String name; - private int age; - - public Student(String name, int age) { - this.name = name; - this.age = age; - } - - public int getAge() { - return age; - } - - public String getName() { - return name; - } -} -``` - -也就是说,外部现在只能通过调用我定义的方法来获取成员属性,而我们可以在这个方法中进行一些额外的操作,比如小明可以修改名字,但是名字中不能包含"小"这个字。 - -```java -public void setName(String name) { - if(name.contains("小")) return; - this.name = name; -} -``` - -单独给外部开放设置名称的方法,因为我还需要做一些额外的处理,所以说不能给外部直接操作成员变量的权限! - -封装思想其实就是把实现细节给隐藏了,外部只需知道这个方法是什么作用,而无需关心实现。 - -封装就是通过访问权限控制来实现的。 - -### 继承 - -继承属于非常重要的内容,在定义不同类的时候存在一些相同属性,为了方便使用可以将这些共同属性抽象成一个父类,在定义其他子类时可以继承自该父类,减少代码的重复定义,子类可以使用父类中**非私有**的成员。 - -现在学生分为两种,艺术生和体育生,他们都是学生的分支,但是他们都有自己的方法: - -```java -public class SportsStudent extends Student{ //通过extends关键字来继承父类 - - public SportsStudent(String name, int age) { - super(name, age); //必须先通过super关键字(指代父类),实现父类的构造方法! - } - - public void exercise(){ - System.out.println("我超勇的!"); - } -} - -public class ArtStudent extends Student{ - - public ArtStudent(String name, int age) { - super(name, age); - } - - public void art(){ - System.out.println("随手画个毕加索!"); - } -} -``` - -子类具有父类的全部属性,protected可见但外部无法使用(包括`private`属性,不可见,无法使用),同时子类还能有自己的方法。继承只能继承一个父类,不支持多继承! - -每一个子类必须定义一个实现父类构造方法的构造方法,也就是需要在构造方法开始使用`super()`,如果父类使用的是默认构造方法,那么子类不用手动指明。 - -所有类都默认继承自Object类,除非手动指定类型,但是依然改变不了最顶层的父类是Object类。所有类都包含Object类中的方法,比如: - -```java -public static void main(String[] args) { -Object obj = new Object; -System.out.println(obj.hashCode()); //求对象的hashcode,默认是对象的内存地址 -System.out.println(obj.equals(obj)); //比较对象是否相同,默认比较的是对象的内存地址,也就是等同于 == -System.out.println(obj.toString()); //将对象转换为字符串,默认生成对象的类名称+hashcode -} -``` - -关于Object类的其他方法,我们会在Java多线程中再来提及。 - -### 多态 - -多态是同一个行为具有多个不同表现形式或形态的能力。也就是同样的方法,由于实现类不同,执行的结果也不同! - -#### 方法的重写 - -我们之前学习了方法的重载,方法的重写和重载是不一样的,重载是原有的方法逻辑不变的情况下,支持更多参数的实现,而重写是直接覆盖原有方法! - -```java -//父类中的study -public void study(){ - System.out.println("学习"); -} - -//子类中的study -@Override //声明这个方法是重写的,但是可以不要,我们现阶段不接触 -public void study(){ - System.out.println("给你看点好康的"); -} -``` - -再次定义同样的方法后,父类的方法就被覆盖!子类还可以给父类方法提升访问权限! - -```java -public static void main(String[] args) { - SportsStudent student = new SportsStudent("lbw", 20); - student.study(); //输出子类定义的内容 -} -``` - -思考:静态方法能被重写吗? - -当我们在重写方法时,不仅想使用我们自己的逻辑,同时还希望执行父类的逻辑(也就是调用父类的方法)怎么办呢? - -```java -public void study(){ - super.study(); - System.out.println("给你看点好康的"); -} -``` - -同理,如果想访问父类的成员变量,也可以使用super关键字来访问,注意,子类可以具有和父类相同的成员变量!而在方法中访问的默认是 形参列表中 > 当前类的成员变量 > 父类成员变量 - -```java -public void setTest(int test){ - test = 1; - this.test = 1; - super.test = 1; -} -``` - -#### 再谈类型转换 - -我们曾经学习过基本数据类型的类型转换,支持一种数据类型转换为另一种数据类型,而我们的类也是支持类型转换的(仅限于存在亲缘关系的类之间进行转换)比如子类可以直接向上转型: - -```java -Student student = new SportsStudent("lbw", 20); //父类变量引用子类实例 -student.study(); //得到依然是具体实现的结果,而不是当前类型的结果 -``` - -我们也可以把已经明确是由哪个类实现的父类引用,强制转换为对应的类型: - -```java -Student student = new SportsStudent("lbw", 20); //是由SportsStudent进行实现的 -//... do something... - -SportsStudent ps = (SportsStudent)student; //让它变成一个具体的子类 -ps.sport(); //调用具体实现类的方法 -``` - -这样的类型转换称为向下转型。 - -#### instanceof关键字 - -那么我们如果只是得到一个父类引用,但是不知道它到底是哪一个子类的实现怎么办?我们可以使用instanceof关键字来实现,它能够进行类型判断! - -```java -private static void test(Student student){ - if (student instanceof SportsStudent){ - SportsStudent sportsStudent = (SportsStudent) student; - sportsStudent.sport(); - }else if (student instanceof ArtStudent){ - ArtStudent artStudent = (ArtStudent) student; - artStudent.art(); - } -} -``` - -通过进行类型判断,我们就可以明确类的具体实现到底是哪个类! - -思考:`student instanceof Student`的结果是什么? - -#### 再谈final关键字 - -我们目前只知道`final`关键字能够使得一个变量的值不可更改,那么如果在类前面声明final,会发生什么? - -```java -public final class Student { //类被声明为终态,那么它还能被继承吗 - -} -``` - -类一旦被声明为终态,将无法再被继承,不允许子类的存在!而方法被声明为final呢? - -```java -public final void study(){ //还能重写吗 - System.out.println("学习"); -} -``` - -如果类的成员属性被声明为final,那么必须在构造方法中或是在定义时赋初始值! - -```java -private final String name; //引用类型不允许再指向其他对象 -private final int age; //基本类型值不允许发生改变 - -public Student(String name, int age) { - this.name = name; - this.age = age; -} -``` - -学习完封装继承和多态之后,我们推荐在不会再发生改变的成员属性上添加final关键字,JVM会对添加了final关键字的属性进行优化! - -#### 抽象类 - -类本身就是一种抽象,而抽象类,把类还要抽象,也就是说,抽象类可以只保留特征,而不保留具体呈现形态,比如方法可以定义好,但是我可以不去实现它,而是交由子类来进行实现! - -```java -public abstract class Student { //抽象类 - public abstract void test(); //抽象方法 -} -``` - -通过使用`abstract`关键字来表明一个类是一个抽象类,抽象类可以使用`abstract`关键字来表明一个方法为抽象方法,也可以定义普通方法,抽象方法不需要编写具体实现(无方法体)但是**必须**由子类实现(除非子类也是一个抽象类)! - -抽象类由于不是具体的类定义,因此无法直接通过new关键字来创建对象! - -```java -Student s = new Student(){ //只能直接创建带实现的匿名内部类! - public void test(){ - - } -} -``` - -因此,抽象类一般只用作继承使用!抽象类使得继承关系之间更加明确: - -```java -public void study(){ //现在只能由子类编写,父类没有定义,更加明确了多态的定义!同一个方法多种实现! - System.out.println("给你看点好康的"); -} -``` - -#### 接口 - -接口甚至比抽象类还抽象,他只代表某个确切的功能!也就是只包含方法的定义,甚至都不是一个类!接口包含了一些列方法的具体定义,类可以实现这个接口,表示类支持接口代表的功能(类似于一个插件,只能作为一个附属功能加在主体上,同时具体实现还需要由主体来实现) - -```java -public interface Eat { - void eat(); -} -``` - -通过使用`interface`关键字来表明是一个接口(注意,这里class关键字被替换为了interface)接口只能包含`public`权限的**抽象方法**!(Java8以后可以有默认实现)我们可以通过声明`default`关键字来给抽象方法一个默认实现: - -```java -public interface Eat { - default void eat(){ - //do something... - } -} -``` - -接口中定义的变量,默认为public static final - -```java -public interface Eat { - int a = 1; - void eat(); -} -``` - -一个类可以实现很多个接口,但是不能理解为多继承!(实际上实现接口是附加功能,和继承的概念有一定出入,顶多说是多继承的一种替代方案)一个类可以附加很多个功能! - -```java -public class SportsStudent extends Student implements Eat, ...{ - @Override - public void eat() { - - } -} -``` - -类通过`implements`关键字来声明实现的接口!每个接口之间用逗号隔开! - -实现接口的类也能通过instanceof关键字判断,也支持向上和向下转型! - -## 内部类 - -类中可以存在一个类!各种各样的长相怪异的代码就是从这里开始出现的! - -### 成员内部类 - -我们的类中可以在嵌套一个类: - -```java -public class Test { - class Inner{ //类中定义的一个内部类 - - } -} -``` - -成员内部类和成员变量和成员方法一样,都是属于对象的,也就是说,必须存在外部对象,才能创建内部类的对象! - -```java -public static void main(String[] args) { - Test test = new Test(); - Test.Inner inner = test.new Inner(); //写法有那么一丝怪异,但是没毛病! -} -``` - -### 静态内部类 - -静态内部类其实就和类中的静态变量和静态方法一样,是属于类拥有的,我们可以直接通过`类名.`去访问: - -```java -public class Test { - static class Inner{ - - } -} - -public static void main(String[] args) { - Test.Inner inner = new Test.Inner(); //不用再创建外部类对象了! -} -``` - -### 局部内部类 - -对,你没猜错,就是和局部变量一样哒~ - -```java -public class Test { - public void test(){ - class Inner{ - - } - - Inner inner = new Inner(); - } -} -``` - -反正我是没用过!内部类 -> 累不累 -> 反正我累了! - -### 匿名内部类 - -匿名内部类才是我们的重点,也是实现lambda表达式的原理!匿名内部类其实就是在new的时候,直接对接口或是抽象类的实现: - -```java -public static void main(String[] args) { - Eat eat = new Eat() { - @Override - public void eat() { - //DO something... - } - }; - } -``` - -我们不用单独去创建一个类来实现,而是可以直接在new的时候写对应的实现!但是,这样写,无法实现复用,只能在这里使用! - -#### lambda表达式 - -读作`λ`表达式,它其实就是我们接口匿名实现的简化,比如说: - -```java -public static void main(String[] args) { - Eat eat = new Eat() { - @Override - public void eat() { - //DO something... - } - }; - } - -public static void main(String[] args) { - Eat eat = () -> {}; //等价于上述内容 - } -``` - -lambda表达式(匿名内部类)只能访问外部的final类型或是隐式final类型的局部变量! - -为了方便,JDK默认就为我们提供了专门写函数式的接口,这里只介绍Consumer - -## 枚举类 - -假设现在我们想给小明添加一个状态(跑步、学习、睡觉),外部可以实时获取小明的状态: - -```java -public class Student { - private final String name; - private final int age; - private String status; - - //... - - public void setStatus(String status) { - this.status = status; - } - - public String getStatus() { - return status; - } -} -``` - -但是这样会出现一个问题,如果我们仅仅是存储字符串,似乎外部可以不按照我们规则,传入一些其他的字符串。这显然是不够严谨的! - -有没有一种办法,能够更好地去实现这样的状态标记呢?我们希望开发者拿到使用的就是我们定义好的状态,我们可以使用枚举类! - -```java -public enum Status { - RUNNING, STUDY, SLEEP //直接写每个状态的名字即可,分号可以不打,但是推荐打上 -} -``` - -使用枚举类也非常方便,我们只需要直接访问即可 - -```java -public class Student { - private final String name; - private final int age; - private Status status; - - //... - - public void setStatus(Status status) { //不再是String,而是我们指定的枚举类型 - this.status = status; - } - - public Status getStatus() { - return status; - } -} - -public static void main(String[] args) { - Student student = new Student("小明", 18); - student.setStatus(Status.RUNNING); - System.out.println(student.getStatus()); -} -``` - -枚举类型使用起来就非常方便了,其实枚举类型的本质就是一个普通的类,但是它继承自`Enum`类,我们定义的每一个状态其实就是一个`public static final`的Status类型成员变量! - -```java -// Compiled from "Status.java" -public final class com.test.Status extends java.lang.Enum { - public static final com.test.Status RUNNING; - public static final com.test.Status STUDY; - public static final com.test.Status SLEEP; - public static com.test.Status[] values(); - public static com.test.Status valueOf(java.lang.String); - static {}; -} -``` - -既然枚举类型是普通的类,那么我们也可以给枚举类型添加独有的成员方法 - -```java -public enum Status { - RUNNING("睡觉"), STUDY("学习"), SLEEP("睡觉"); //无参构造方法被覆盖,创建枚举需要添加参数(本质就是调用的构造方法!) - - private final String name; //枚举的成员变量 - Status(String name){ //覆盖原有构造方法(默认private,只能内部使用!) - this.name = name; - } - - public String getName() { //获取封装的成员变量 - return name; - } -} - -public static void main(String[] args) { - Student student = new Student("小明", 18); - student.setStatus(Status.RUNNING); - System.out.println(student.getStatus().getName()); -} -``` - -枚举类还自带一些继承下来的实用方法 - -```java -Status.valueOf("") //将名称相同的字符串转换为枚举 -Status.values() //快速获取所有的枚举 -``` - -## 基本类型包装类 - -Java并不是纯面向对象的语言,虽然Java语言是一个面向对象的语言,但是Java中的基本数据类型却不是面向对象的。在学习泛型和集合之前,基本类型的包装类是一定要讲解的内容! - -我们的基本类型,如果想通过对象的形式去使用他们,Java提供的基本类型包装类,使得Java能够更好的体现面向对象的思想,同时也使得基本类型能够支持对象操作! - -![img](https://img2018.cnblogs.com/blog/1504650/201901/1504650-20190122173636211-1359168032.png) - -* byte -> Byte -* boolean -> Boolean -* short -> Short -* char -> Character -* int -> Integer -* long -> Long -* float -> Float -* double -> Double - -包装类实际上就行将我们的基本数据类型,封装成一个类(运用了封装的思想) - -```java -private final int value; //Integer内部其实本质还是存了一个基本类型的数据,但是我们不能直接操作 - -public Integer(int value) { - this.value = value; -} -``` - -现在我们操作的就是Integer对象而不是一个int基本类型了! - -```java -public static void main(String[] args) { - Integer i = 1; //包装类型可以直接接收对应类型的数据,并变为一个对象! - System.out.println(i + i); //包装类型可以直接被当做一个基本类型进行操作! -} -``` - -#### 自动装箱和拆箱 - -那么为什么包装类型能直接使用一个具体值来赋值呢?其实依靠的是自动装箱和拆箱机制 - -```java -Integer i = 1; //其实这里只是简写了而已 -Integer i = Integer.valueOf(1); //编译后真正的样子 -``` - -调用valueOf来生成一个Integer对象! - -```java -public static Integer valueOf(int i) { - if (i >= IntegerCache.low && i <= IntegerCache.high) //注意,Java为了优化,有一个缓存机制,如果是在-128~127之间的数,会直接使用已经缓存好的对象,而不是再去创建新的!(面试常考) - return IntegerCache.cache[i + (-IntegerCache.low)]; - return new Integer(i); //返回一个新创建好的对象 -} -``` - -而如果使用包装类来进行运算,或是赋值给一个基本类型变量,会进行自动拆箱: - -```java -public static void main(String[] args) { - Integer i = Integer.valueOf(1); - int a = i; //简写 - int a = i.intValue(); //编译后实际的代码 - - long c = i.longValue(); //其他类型也有! -} -``` - -既然现在是包装类型了,那么我们还能使用`==`来判断两个数是否相等吗? - -```java -public static void main(String[] args) { - Integer i1 = 28914; - Integer i2 = 28914; - - System.out.println(i1 == i2); //实际上判断是两个对象是否为同一个对象(内存地址是否相同) - System.out.println(i1.equals(i2)); //这个才是真正的值判断! -} -``` - -注意IntegerCache带来的影响! - -思考:下面这种情况结果会是什么? - -```java -public static void main(String[] args) { - Integer i1 = 28914; - Integer i2 = 28914; - - System.out.println(i1+1 == i2+1); -} -``` - -在集合类的学习中,我们还会继续用到我们的包装类型! - -*** - -## 面向对象编程实战 - -虽然我们学习了编程,但是我们不能一股脑的所有问题都照着编程的思维去解决,编程只是解决问题的一种手段,灵活的运用我们所学的知识,才是解决问题的最好办法!比如,求1到100所有数的和: - -```java -public static void main(String[] args) { - int sum = 0; - for (int i = 1; i <= 100; i++) { //for循环暴力求解,简单,但是效率似乎低了一些 - sum += i; - } - System.out.println(sum); -} - -public static void main(String[] args) { - System.out.println((1 + 100) * 50); //高斯求和公式,利用数学,瞬间计算结果! -} -``` - -说到最后,其实数学和逻辑思维才是解决问题的最终办法! - -### 对象设计(面向对象、多态运用) - -* 设计一个Person抽象类,包含吃饭运动学习三种行为,分为工人、学生、老师三种职业。 -* 设计设计一个接口`考试`,只有老师和学生会考试。 -* 设计一个方法,模拟让人类进入考场,要求只有会考试的人才能进入,并且考试。 - -### 二分搜索(搜索算法) - -现在有一个有序数组(从小到大,数组长度 0 < n < 1000000)如何快速寻找我们想要的数在哪个位置,如果存在请返回下标,不存在返回`-1`即可。 - -```java -int[] arr = new int[]{1, 4, 5, 6, 7, 10, 12, 14, 20, 22, 26}; //测试用例 - -private static int test(int[] arr, int target){ - //请在这里实现搜索算法 -} -``` - -### 快速排序(排序算法、递归分治) - -(开始之前先介绍一下递归!)快速排序其实是一种排序执行效率很高的排序算法,它利用**分治法**来对待排序序列进行分治排序,它的思想主要是通过一趟排序将待排记录分隔成独立的两部分,其中的一部分比关键字小,后面一部分比关键字大,然后再对这前后的两部分分别采用这种方式进行排序,通过递归的运算最终达到整个序列有序。 - -快速排序就像它的名字一样,快速!在极端情况下,会退化成冒泡排序! - -### 0/1背包问题(回溯法、剪枝/动态规划优化) - -给定 `n `件物品,每一个物品的重量为 `w[n]`,每个物品的价值为 `v[n]`。现挑选物品放入背包中,假定背包能承受的最大重量为 `capacity`,求装入物品的最大价值是多少? - -```java -int[] w = {2, 3, 4, 5}; -int[] v = {3, 4, 5, 6}; -int capacity = 8; -``` \ No newline at end of file diff --git a/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(五).md b/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(五).md deleted file mode 100644 index 9cfc8b2..0000000 --- a/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(五).md +++ /dev/null @@ -1,720 +0,0 @@ -# Java I/O - -**注意:**这块会涉及到**操作系统**和**计算机组成原理**相关内容。 - -I/O简而言之,就是输入输出,那么为什么会有I/O呢?其实I/O无时无刻都在我们的身边,比如读取硬盘上的文件,网络文件传输,鼠标键盘输入,也可以是接受单片机发回的数据,而能够支持这些操作的设备就是I/O设备。 - -我们可以大致看一下整个计算机的总线结构: - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg2020.cnblogs.com%2Fblog%2F1896043%2F202005%2F1896043-20200507143508957-1866569205.jpg&refer=http%3A%2F%2Fimg2020.cnblogs.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1637387700&t=e6a5ade66f8e4af2ac64d12e6dd77dec) - -常见的I/O设备一般是鼠标、键盘这类通过USB进行传输的外设或者是通过Sata接口或是M.2连接的硬盘。一般情况下,这些设备是由CPU发出指令通过南桥芯片间接进行控制,而不是由CPU直接操作。 - -而我们在程序中,想要读取这些外部连接的I/O设备中的内容,就需要将数据传输到内存中。而需要实现这样的操作,单单凭借一个小的程序是无法做到的,而操作系统(如:Windows/Linux/MacOS)就是专门用于控制和管理计算机硬件和软件资源的软件,我们需要读取一个IO设备的内容时,可以向操作系统发出请求,由操作系统帮助我们来和底层的硬件交互以完成我们的读取/写入请求。从读取硬盘文件的角度来说,不同的操作系统有着不同的文件系统(也就是文件在硬盘中的存储排列方式,如Windows就是NTFS、MacOS就是APFS),硬盘只能存储一个个0和1这样的二进制数据,至于0和1如何排列,各自又代表什么意思,就是由操作系统的文件系统来决定的。从网络通信角度来说,网络信号通过网卡等设备翻译为二进制信号,再交给系统进行读取,最后再由操作系统来给到程序。 - -JDK提供了一套用于IO操作的框架,根据流的传输方向和读取单位,分为字节流InputStream和OutputStream以及字符流Reader和Writer,当然,这里的Stream并不是前面集合框架认识的Stream,这里的流指的是数据流,通过流,我们就可以一直从流中读取数据,直到读取到尽头,或是不断向其中写入数据,直到我们写入完成。而这类IO就是我们所说的BIO, - -字节流一次读取一个字节,也就是一个`byte`的大小,而字符流顾名思义,就是一次读取一个字符,也就是一个`char`的大小(在读取纯文本文件的时候更加适合),有关这两种流,会在后面详细介绍,这个章节我们需要学习16个关键的流。 - -## 文件流 - -要学习和使用IO,首先就要从最易于理解的读取文件开始说起。 - -### 文件字节流 - -首先介绍一下FileInputStream,通过它来获取文件的输入流。 - -```java -public static void main(String[] args) { - try { - FileInputStream inputStream = new FileInputStream("路径"); - //路径支持相对路径和绝对路径 - } catch (FileNotFoundException e) { - e.printStackTrace(); - } -} -``` - -相对路径是在当前运行的路径下寻找文件,而绝对路径,是从根目录开始寻找。路径分割符支持使用`/`或是`\\`,但是不能写为`\`因为它是转义字符! - -在使用完成一个流之后,必须关闭这个流来完成对资源的释放,否则资源会被一直占用! - -```java -public static void main(String[] args) { - FileInputStream inputStream = null; //定义可以先放在try外部 - try { - inputStream = new FileInputStream("路径"); - } catch (FileNotFoundException e) { - e.printStackTrace(); - } finally { - try { //建议在finally中进行,因为这个是任何情况都必须要执行的! - if(inputStream != null) inputStream.close(); - } catch (IOException e) { - e.printStackTrace(); - } - } -} -``` - -虽然这样的写法才是最保险的,但是显得过于繁琐了,尤其是finally中再次嵌套了一个try-catch块,因此在JDK1.7新增了try-with-resource语法,用于简化这样的写法(本质上还是和这样的操作一致,只是换了个写法) - -```java -public static void main(String[] args) { - - //注意,这种语法只支持实现了AutoCloseable接口的类! - try(FileInputStream inputStream = new FileInputStream("路径")) { //直接在try()中定义要在完成之后释放的资源 - - } catch (IOException e) { //这里变成IOException是因为调用close()可能会出现,而FileNotFoundException是继承自IOException的 - e.printStackTrace(); - } - //无需再编写finally语句块,因为在最后自动帮我们调用了close() -} -``` - -之后为了方便,我们都使用此语法进行教学。 - -```java -public static void main(String[] args) { - //test.txt:a - try(FileInputStream inputStream = new FileInputStream("test.txt")) { - //使用read()方法进行字符读取 - System.out.println((char) inputStream.read()); //读取一个字节的数据(英文字母只占1字节,中文占2字节) - System.out.println(inputStream.read()); //唯一一个字节的内容已经读完了,再次读取返回-1表示没有内容了 - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -使用read可以直接读取一个字节的数据,注意,流的内容是有限的,读取一个少一个!我们如果想一次性全部读取的话,可以直接使用一个while循环来完成: - -```java -public static void main(String[] args) { - //test.txt:abcd - try(FileInputStream inputStream = new FileInputStream("test.txt")) { - int tmp; - while ((tmp = inputStream.read()) != -1){ //通过while循环来一次性读完内容 - System.out.println((char)tmp); - } - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -使用方法能查看当前可读的剩余字节数量(注意:并不一定真实的数据量就是这么多,尤其是在网络I/O操作时,这个方法只能进行一个预估也可以说是暂时能一次性读取的数量) - -```java -try(FileInputStream inputStream = new FileInputStream("test.txt")) { - System.out.println(inputStream.available()); //查看剩余数量 -}catch (IOException e){ - e.printStackTrace(); -} -``` - -当然,一个一个读取效率太低了,那能否一次性全部读取呢?我们可以预置一个合适容量的byte[]数组来存放。 - -```java -public static void main(String[] args) { - //test.txt:abcd - try(FileInputStream inputStream = new FileInputStream("test.txt")) { - byte[] bytes = new byte[inputStream.available()]; //我们可以提前准备好合适容量的byte数组来存放 - System.out.println(inputStream.read(bytes)); //一次性读取全部内容(返回值是读取的字节数) - System.out.println(new String(bytes)); //通过String(byte[])构造方法得到字符串 - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -也可以控制要读取数量: - -```java -System.out.println(inputStream.read(bytes, 1, 2)); //第二个参数是从给定数组的哪个位置开始放入内容,第三个参数是读取流中的字节数 -``` - -**注意**:一次性读取同单个读取一样,当没有任何数据可读时,依然会返回-1 - -通过`skip()`方法可以跳过指定数量的字节: - -```java -public static void main(String[] args) { - //test.txt:abcd - try(FileInputStream inputStream = new FileInputStream("test.txt")) { - System.out.println(inputStream.skip(1)); - System.out.println((char) inputStream.read()); //跳过了一个字节 - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -注意:FileInputStream是不支持`reset()`的,虽然有这个方法,但是这里先不提及。 - -既然有输入流,那么文件输出流也是必不可少的: - -```java -public static void main(String[] args) { - //输出流也需要在最后调用close()方法,并且同样支持try-with-resource - try(FileOutputStream outputStream = new FileOutputStream("output.txt")) { - //注意:若此文件不存在,会直接创建这个文件! - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -输出流没有`read()`操作而是`write()`操作,使用方法同输入流一样,只不过现在的方向变为我们向文件里写入内容: - -```java -public static void main(String[] args) { - try(FileOutputStream outputStream = new FileOutputStream("output.txt")) { - outputStream.write('c'); //同read一样,可以直接写入内容 - outputStream.write("lbwnb".getBytes()); //也可以直接写入byte[] - outputStream.write("lbwnb".getBytes(), 0, 1); //同上输入流 - outputStream.flush(); //建议在最后执行一次刷新操作(强制写入)来保证数据正确写入到硬盘文件中 - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -那么如果是我只想在文件尾部进行追加写入数据呢?我们可以调用另一个构造方法来实现: - -```java -public static void main(String[] args) { - try(FileOutputStream outputStream = new FileOutputStream("output.txt", true)) { - outputStream.write("lb".getBytes()); //现在只会进行追加写入,而不是直接替换原文件内容 - outputStream.flush(); - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -利用输入流和输出流,就可以轻松实现文件的拷贝了: - -```java -public static void main(String[] args) { - try(FileOutputStream outputStream = new FileOutputStream("output.txt"); - FileInputStream inputStream = new FileInputStream("test.txt")) { //可以写入多个 - byte[] bytes = new byte[10]; //使用长度为10的byte[]做传输媒介 - int tmp; //存储本地读取字节数 - while ((tmp = inputStream.read(bytes)) != -1){ //直到读取完成为止 - outputStream.write(bytes, 0, tmp); //写入对应长度的数据到输出流 - } - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -### 文件字符流 - -字符流不同于字节,字符流是以一个具体的字符进行读取,因此它只适合读纯文本的文件,如果是其他类型的文件不适用: - -```java -public static void main(String[] args) { - try(FileReader reader = new FileReader("test.txt")){ - reader.skip(1); //现在跳过的是一个字符 - System.out.println((char) reader.read()); //现在是按字符进行读取,而不是字节,因此可以直接读取到中文字符 - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -同理,字符流只支持`char[]`类型作为存储: - -```java -public static void main(String[] args) { - try(FileReader reader = new FileReader("test.txt")){ - char[] str = new char[10]; - reader.read(str); - System.out.println(str); //直接读取到char[]中 - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -既然有了Reader肯定也有Writer: - -```java -public static void main(String[] args) { - try(FileWriter writer = new FileWriter("output.txt")){ - writer.getEncoding(); //支持获取编码(不同的文本文件可能会有不同的编码类型) - writer.write('牛'); - writer.append('牛'); //其实功能和write一样 - writer.flush(); //刷新 - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -我们发现不仅有`write()`方法,还有一个`append()`方法,但是实际上他们效果是一样的,看源码: - -```java -/** - * Appends the specified character to this writer. - * - *

An invocation of this method of the form out.append(c) - * behaves in exactly the same way as the invocation - * - *

- *     out.write(c) 
- * - * @param c - * The 16-bit character to append - * - * @return This writer - * - * @throws IOException - * If an I/O error occurs - * - * @since 1.5 - */ -public Writer append(char c) throws IOException { - write(c); - return this; -} -``` - -append支持像StringBuilder那样的链式调用,返回的是Writer对象本身。 - -**练习**:尝试一下用Reader和Writer来拷贝纯文本文件 - -### File类 - -File类专门用于表示一个文件或文件夹,只不过它只是代表这个文件,但并不是这个文件本身。通过File对象,可以更好地管理和操作硬盘上的文件。 - -```java -public static void main(String[] args) { - File file = new File("test.txt"); //直接创建文件对象,可以是相对路径,也可以是绝对路径 - System.out.println(file.exists()); //此文件是否存在 - System.out.println(file.length()); //获取文件的大小 - System.out.println(file.isDirectory()); //是否为一个文件夹 - System.out.println(file.canRead()); //是否可读 - System.out.println(file.canWrite()); //是否可写 - System.out.println(file.canExecute()); //是否可执行 -} -``` - -通过File对象,我们就能快速得到文件的所有信息,如果是文件夹,还可以获取文件夹内部的文件列表等内容: - -```java -File file = new File("/"); -System.out.println(Arrays.toString(file.list())); //快速获取文件夹下的文件名称列表 -for (File f : file.listFiles()){ //所有子文件的File对象 - System.out.println(f.getAbsolutePath()); //获取文件的绝对路径 -} -``` - -如果我们希望读取某个文件的内容,可以直接将File作为参数传入字节流或是字符流: - -```java -File file = new File("test.txt"); -try (FileInputStream inputStream = new FileInputStream(file)){ //直接做参数 - System.out.println(inputStream.available()); -}catch (IOException e){ - e.printStackTrace(); -} -``` - -**练习**:尝试拷贝文件夹下的所有文件到另一个文件夹 - -*** - -## 缓冲流 - -虽然普通的文件流读取文件数据非常便捷,但是每次都需要从外部I/O设备去获取数据,由于外部I/O设备的速度一般都达不到内存的读取速度,很有可能造成程序反应迟钝,因此性能还不够高,而缓冲流正如其名称一样,它能够提供一个缓冲,提前将部分内容存入内存(缓冲区)在下次读取时,如果缓冲区中存在此数据,则无需再去请求外部设备。同理,当向外部设备写入数据时,也是由缓冲区处理,而不是直接向外部设备写入。 - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fwww.wityx.com%2Fimage%2F201908%2F480873DBD936EBA9518F721ACDC22BFE.png&refer=http%3A%2F%2Fwww.wityx.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1637457276&t=b4f7d52f08d9d5815baca0b21a01f925) - -### 缓冲字节流 - -要创建一个缓冲字节流,只需要将原本的流作为构造参数传入BufferedInputStream即可: - -```java -public static void main(String[] args) { - try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("test.txt"))){ //传入FileInputStream - System.out.println((char) bufferedInputStream.read()); //操作和原来的流是一样的 - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -实际上进行I/O操作的并不是BufferedInputStream,而是我们传入的FileInputStream,而BufferedInputStream虽然有着同样的方法,但是进行了一些额外的处理然后再调用FileInputStream的同名方法,这样的写法称为`装饰者模式` - -```java -public void close() throws IOException { - byte[] buffer; - while ( (buffer = buf) != null) { - if (bufUpdater.compareAndSet(this, buffer, null)) { //CAS无锁算法,并发会用到,暂时不管 - InputStream input = in; - in = null; - if (input != null) - input.close(); - return; - } - // Else retry in case a new buf was CASed in fill() - } -} -``` - -实际上这种模式是父类FilterInputStream提供的规范,后面我们还会讲到更多FilterInputStream的子类。 - -我们可以发现在BufferedInputStream中还存在一个专门用于缓存的数组: - -```java -/** - * The internal buffer array where the data is stored. When necessary, - * it may be replaced by another array of - * a different size. - */ -protected volatile byte buf[]; -``` - -I/O操作一般不能重复读取内容(比如键盘发送的信号,主机接收了就没了),而缓冲流提供了缓冲机制,一部分内容可以被暂时保存,BufferedInputStream支持`reset()`和`mark()`操作,首先我们来看看`mark()`方法的介绍: - -```java -/** - * Marks the current position in this input stream. A subsequent - * call to the reset method repositions this stream at - * the last marked position so that subsequent reads re-read the same bytes. - *

- * The readlimit argument tells this input stream to - * allow that many bytes to be read before the mark position gets - * invalidated. - *

- * This method simply performs in.mark(readlimit). - * - * @param readlimit the maximum limit of bytes that can be read before - * the mark position becomes invalid. - * @see java.io.FilterInputStream#in - * @see java.io.FilterInputStream#reset() - */ -public synchronized void mark(int readlimit) { - in.mark(readlimit); -} -``` - -当调用`mark()`之后,输入流会以某种方式保留之后读取的`readlimit`数量的内容,当读取的内容数量超过`readlimit`则之后的内容不会被保留,当调用`reset()`之后,会使得当前的读取位置回到`mark()`调用时的位置。 - -```java -public static void main(String[] args) { - try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("test.txt"))){ - bufferedInputStream.mark(1); //只保留之后的1个字符 - System.out.println((char) bufferedInputStream.read()); - System.out.println((char) bufferedInputStream.read()); - bufferedInputStream.reset(); //回到mark时的位置 - System.out.println((char) bufferedInputStream.read()); - System.out.println((char) bufferedInputStream.read()); - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -我们发现虽然后面的部分没有保存,但是依然能够正常读取,其实`mark()`后保存的读取内容是取`readlimit`和BufferedInputStream类的缓冲区大小两者中的最大值,而并非完全由`readlimit`确定。因此我们限制一下缓冲区大小,再来观察一下结果: - -```java -public static void main(String[] args) { - try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("test.txt"), 1)){ //将缓冲区大小设置为1 - bufferedInputStream.mark(1); //只保留之后的1个字符 - System.out.println((char) bufferedInputStream.read()); - System.out.println((char) bufferedInputStream.read()); //已经超过了readlimit,继续读取会导致mark失效 - bufferedInputStream.reset(); //mark已经失效,无法reset() - System.out.println((char) bufferedInputStream.read()); - System.out.println((char) bufferedInputStream.read()); - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -了解完了BufferedInputStream之后,我们再来看看BufferedOutputStream,其实和BufferedInputStream原理差不多,只是反向操作: - -```java -public static void main(String[] args) { - try (BufferedOutputStream outputStream = new BufferedOutputStream(new FileOutputStream("output.txt"))){ - outputStream.write("lbwnb".getBytes()); - outputStream.flush(); - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -操作和FileOutputStream一致,这里就不多做介绍了。 - -### 缓冲字符流 - -缓存字符流和缓冲字节流一样,也有一个专门的缓冲区,BufferedReader构造时需要传入一个Reader对象: - -```java -public static void main(String[] args) { - try (BufferedReader reader = new BufferedReader(new FileReader("test.txt"))){ - System.out.println((char) reader.read()); - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -使用和reader也是一样的,内部也包含一个缓存数组: - -```java -private char cb[]; -``` - -相比Reader更方便的是,它支持按行读取: - -```java -public static void main(String[] args) { - try (BufferedReader reader = new BufferedReader(new FileReader("test.txt"))){ - System.out.println(reader.readLine()); //按行读取 - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -读取后直接得到一个字符串,当然,它还能把每一行内容依次转换为集合类提到的Stream流: - -```java -public static void main(String[] args) { - try (BufferedReader reader = new BufferedReader(new FileReader("test.txt"))){ - reader - .lines() - .limit(2) - .distinct() - .sorted() - .forEach(System.out::println); - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -它同样也支持`mark()`和`reset()`操作: - -```java -public static void main(String[] args) { - try (BufferedReader reader = new BufferedReader(new FileReader("test.txt"))){ - reader.mark(1); - System.out.println((char) reader.read()); - reader.reset(); - System.out.println((char) reader.read()); - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -BufferedReader处理纯文本文件时就更加方便了,BufferedWriter在处理时也同样方便: - -```java -public static void main(String[] args) { - try (BufferedWriter reader = new BufferedWriter(new FileWriter("output.txt"))){ - reader.newLine(); //使用newLine进行换行 - reader.write("汉堡做滴彳亍不彳亍"); //可以直接写入一个字符串 - reader.flush(); //清空缓冲区 - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -*** - -## 转换流 - -有时会遇到这样一个很麻烦的问题:我这里读取的是一个字符串或是一个个字符,但是我只能往一个OutputStream里输出,但是OutputStream又只支持byte类型,如果要往里面写入内容,进行数据转换就会很麻烦,那么能否有更加简便的方式来做这样的事情呢? - -```java -public static void main(String[] args) { - try(OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream("test.txt"))){ //虽然给定的是FileOutputStream,但是现在支持以Writer的方式进行写入 - writer.write("lbwnb"); //以操作Writer的样子写入OutputStream - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -同样的,我们现在只拿到了一个InputStream,但是我们希望能够按字符的方式读取,我们就可以使用InputStreamReader来帮助我们实现: - -```java -public static void main(String[] args) { - try(InputStreamReader reader = new InputStreamReader(new FileInputStream("test.txt"))){ //虽然给定的是FileInputStream,但是现在支持以Reader的方式进行读取 - System.out.println((char) reader.read()); - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -InputStreamReader和OutputStreamWriter本质也是Reader和Writer,因此可以直接放入BufferedReader来实现更加方便的操作。 - -*** - -## 打印流 - -打印流其实我们从一开始就在使用了,比如`System.out`就是一个PrintStream,PrintStream也继承自FilterOutputStream类因此依然是装饰我们传入的输出流,但是它存在自动刷新机制,例如当向PrintStream流中写入一个字节数组后自动调用`flush()`方法。PrintStream也永远不会抛出异常,而是使用内部检查机制`checkError()`方法进行错误检查。最方便的是,它能够格式化任意的类型,将它们以字符串的形式写入到输出流。 - -```java -public final static PrintStream out = null; -``` - -可以看到`System.out`也是PrintStream,不过默认是向控制台打印,我们也可以让它向文件中打印: - -```java -public static void main(String[] args) { - try(PrintStream stream = new PrintStream(new FileOutputStream("test.txt"))){ - stream.println("lbwnb"); //其实System.out就是一个PrintStream - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -我们平时使用的`println`方法就是PrintStream中的方法,它会直接打印基本数据类型或是调用对象的`toString()`方法得到一个字符串,并将字符串转换为字符,放入缓冲区再经过转换流输出到给定的输出流上。 - -![img](https://img-blog.csdn.net/20180906143936647?watermark/2/text/aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2xpbGkxMzg5Nzc0MTU1NA==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70) - -因此实际上内部还包含这两个内容: - -```java -/** - * Track both the text- and character-output streams, so that their buffers - * can be flushed without flushing the entire stream. - */ -private BufferedWriter textOut; -private OutputStreamWriter charOut; -``` - -与此相同的还有一个PrintWriter,不过他们的功能基本一致,PrintWriter的构造方法可以接受一个Writer作为参数,这里就不再做过多阐述了。 - -*** - -## 数据流 - -数据流DataInputStream也是FilterInputStream的子类,同样采用装饰者模式,最大的不同是它支持基本数据类型的直接读取: - -```java -public static void main(String[] args) { - try (DataInputStream dataInputStream = new DataInputStream(new FileInputStream("test.txt"))){ - System.out.println(dataInputStream.readBoolean()); //直接将数据读取为任意基本数据类型 - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -用于写入基本数据类型: - -```java -public static void main(String[] args) { - try (DataOutputStream dataOutputStream = new DataOutputStream(new FileOutputStream("output.txt"))){ - dataOutputStream.writeBoolean(false); - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -注意,写入的是二进制数据,并不是写入的字符串,使用DataInputStream可以读取,一般他们是配合一起使用的。 - -## 对象流 - -既然基本数据类型能够读取和写入基本数据类型,那么能否将对象也支持呢?ObjectOutputStream不仅支持基本数据类型,通过对对象的序列化操作,以某种格式保存对象,来支持对象类型的IO,注意:它不是继承自FilterInputStream的。 - -```java -public static void main(String[] args) { - try (ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("output.txt")); - ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("output.txt"))){ - People people = new People("lbw"); - outputStream.writeObject(people); - outputStream.flush(); - people = (People) inputStream.readObject(); - System.out.println(people.name); - }catch (IOException | ClassNotFoundException e) { - e.printStackTrace(); - } -} - -static class People implements Serializable{ //必须实现Serializable接口才能被序列化 - String name; - - public People(String name){ - this.name = name; - } -} -``` - -在我们后续的操作中,有可能会使得这个类的一些结构发生变化,而原来保存的数据只适用于之前版本的这个类,因此我们需要一种方法来区分类的不同版本: - -```java -static class People implements Serializable{ - private static final long serialVersionUID = 123456; //在序列化时,会被自动添加这个属性,它代表当前类的版本,我们也可以手动指定版本。 - - String name; - - public People(String name){ - this.name = name; - } -} -``` - -当发生版本不匹配时,会无法反序列化为对象: - -```java -java.io.InvalidClassException: com.test.Main$People; local class incompatible: stream classdesc serialVersionUID = 123456, local class serialVersionUID = 1234567 - at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:699) - at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:2003) - at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1850) - at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2160) - at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1667) - at java.io.ObjectInputStream.readObject(ObjectInputStream.java:503) - at java.io.ObjectInputStream.readObject(ObjectInputStream.java:461) - at com.test.Main.main(Main.java:27) -``` - -如果我们不希望某些属性参与到序列化中进行保存,我们可以添加`transient`关键字: - -```java -public static void main(String[] args) { - try (ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("output.txt")); - ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("output.txt"))){ - People people = new People("lbw"); - outputStream.writeObject(people); - outputStream.flush(); - people = (People) inputStream.readObject(); - System.out.println(people.name); //虽然能得到对象,但是name属性并没有保存,因此为null - }catch (IOException | ClassNotFoundException e) { - e.printStackTrace(); - } -} - -static class People implements Serializable{ - private static final long serialVersionUID = 1234567; - - transient String name; - - public People(String name){ - this.name = name; - } -} -``` - -其实我们可以看到,在一些JDK内部的源码中,也存在大量的transient关键字,使得某些属性不参与序列化,取消这些不必要保存的属性,可以节省数据空间占用以及减少序列化时间。 - -*** - -## Java I/O编程实战 - -### 图书管理系统 - -要求实现一个图书管理系统(控制台),支持以下功能:保存书籍信息(要求持久化),查询、添加、删除、修改书籍信息。 diff --git a/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(六).md b/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(六).md deleted file mode 100644 index 2cf3842..0000000 --- a/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(六).md +++ /dev/null @@ -1,1020 +0,0 @@ -# Java多线程 - -**注意:**本章节会涉及到 **操作系统** 相关知识。 - -在了解多线程之前,让我们回顾一下`操作系统`中提到的进程概念: - -![img](https://img0.baidu.com/it/u=2613039280,4140201323&fm=26&fmt=auto) - -进程是程序执行的实体,每一个进程都是一个应用程序(比如我们运行QQ、浏览器、LOL、网易云音乐等软件),都有自己的内存空间,CPU一个核心同时只能处理一件事情,当出现多个进程需要同时运行时,CPU一般通过`时间片轮转调度`算法,来实现多个进程的同时运行。 - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fhiphotos.baidu.com%2Fdoc%2Fpic%2Fitem%2Faec379310a55b3193e6caaf24aa98226cefc179b.jpg&refer=http%3A%2F%2Fhiphotos.baidu.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1637499744&t=1df3c2095bc9a8cbe8cd9d0974644b7c) - -在早期的计算机中,进程是拥有资源和独立运行的最小单位,也是程序执行的最小单位。但是,如果我希望两个任务同时进行,就必须运行两个进程,由于每个进程都有一个自己的内存空间,进程之间的通信就变得非常麻烦(比如要共享某些数据)而且执行不同进程会产生上下文切换,非常耗时,那么能否实现在一个进程中就能够执行多个任务呢? - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fs2.51cto.com%2Fwyfs02%2FM00%2F84%2F3A%2FwKiom1eIqY7il2J7AAAyvcssSjs721.gif&refer=http%3A%2F%2Fs2.51cto.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1637474421&t=aef9a39ea3a09d6d67e8d4b769036446) - -后来,线程横空出世,一个进程可以有多个线程,线程是程序执行中一个单一的顺序控制流程,现在线程才是程序执行流的最小单元,各个线程之间共享程序的内存空间(也就是所在进程的内存空间),上下文切换速度也高于进程。 - -在Java中,我们从开始,一直以来编写的都是单线程应用程序(运行`main()`方法的内容),也就是说只能同时执行一个任务(无论你是调用方法、还是进行计算,始终都是依次进行的,也就是同步的),而如果我们希望同时执行多个任务(两个方法**同时**在运行或者是两个计算同时在进行,也就是异步的),就需要用到Java多线程框架。实际上一个Java程序启动后,会创建很多线程,不仅仅只运行一个主线程: - -```java -public static void main(String[] args) { - ThreadMXBean bean = ManagementFactory.getThreadMXBean(); - long[] ids = bean.getAllThreadIds(); - ThreadInfo[] infos = bean.getThreadInfo(ids); - for (ThreadInfo info : infos) { - System.out.println(info.getThreadName()); - } -} -``` - -关于除了main线程默认以外的线程,涉及到JVM相关底层原理,在这里不做讲解,了解就行。 - -*** - -## 线程的创建和启动 - -通过创建Thread对象来创建一个新的线程,Thread构造方法中需要传入一个Runnable接口的实现(其实就是编写要在另一个线程执行的内容逻辑)同时Runnable只有一个未实现方法,因此可以直接使用lambda表达式: - -```java -@FunctionalInterface -public interface Runnable { - /** - * When an object implementing interface Runnable is used - * to create a thread, starting the thread causes the object's - * run method to be called in that separately executing - * thread. - *

- * The general contract of the method run is that it may - * take any action whatsoever. - * - * @see java.lang.Thread#run() - */ - public abstract void run(); -} -``` - -创建好后,通过调用`start()`方法来运行此线程: - -```java -public static void main(String[] args) { - Thread t = new Thread(() -> { //直接编写逻辑 - System.out.println("我是另一个线程!"); - }); - t.start(); //调用此方法来开始执行此线程 -} -``` - -可能上面的例子看起来和普通的单线程没两样,那我们先来看看下面这段代码的运行结果: - -```java -public static void main(String[] args) { - Thread t = new Thread(() -> { - System.out.println("我是线程:"+Thread.currentThread().getName()); - System.out.println("我正在计算 0-10000 之间所有数的和..."); - int sum = 0; - for (int i = 0; i <= 10000; i++) { - sum += i; - } - System.out.println("结果:"+sum); - }); - t.start(); - System.out.println("我是主线程!"); -} -``` - -我们发现,这段代码执行输出结果并不是按照从上往下的顺序了,因为他们分别位于两个线程,他们是同时进行的!如果你还是觉得很疑惑,我们接着来看下面的代码运行结果: - -```java -public static void main(String[] args) { - Thread t1 = new Thread(() -> { - for (int i = 0; i < 50; i++) { - System.out.println("我是一号线程:"+i); - } - }); - Thread t2 = new Thread(() -> { - for (int i = 0; i < 50; i++) { - System.out.println("我是二号线程:"+i); - } - }); - t1.start(); - t2.start(); -} -``` - -我们可以看到打印实际上是在交替进行的,也证明了他们是在同时运行! - -**注意**:我们发现还有一个run方法,也能执行线程里面定义的内容,但是run是直接在当前线程执行,并不是创建一个线程执行! - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fwww.liuhaihua.cn%2Fwp-content%2Fuploads%2F2019%2F09%2F3AfuQrV.png&refer=http%3A%2F%2Fwww.liuhaihua.cn&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1637477978&t=d986b270854b3d7c54f816f9103084bc) - -实际上,线程和进程差不多,也会等待获取CPU资源,一旦获取到,就开始按顺序执行我们给定的程序,当需要等待外部IO操作(比如Scanner获取输入的文本),就会暂时处于休眠状态,等待通知,或是调用`sleep()`方法来让当前线程休眠一段时间: - -```java -public static void main(String[] args) throws InterruptedException { - System.out.println("l"); - Thread.sleep(1000); //休眠时间,以毫秒为单位,1000ms = 1s - System.out.println("b"); - Thread.sleep(1000); - System.out.println("w"); - Thread.sleep(1000); - System.out.println("nb!"); -} -``` - -我们也可以使用`stop()`方法来强行终止此线程: - -```java -public static void main(String[] args) throws InterruptedException { - Thread t = new Thread(() -> { - Thread me = Thread.currentThread(); //获取当前线程对象 - for (int i = 0; i < 50; i++) { - System.out.println("打印:"+i); - if(i == 20) me.stop(); //此方法会直接终止此线程 - } - }); - t.start(); -} -``` - -虽然`stop()`方法能够终止此线程,但是并不是所推荐的做法,有关线程中断相关问题,我们会在后面继续了解。 - -**思考**:猜猜以下程序输出结果: - -```java -private static int value = 0; - -public static void main(String[] args) throws InterruptedException { - Thread t1 = new Thread(() -> { - for (int i = 0; i < 10000; i++) value++; - System.out.println("线程1完成"); - }); - Thread t2 = new Thread(() -> { - for (int i = 0; i < 10000; i++) value++; - System.out.println("线程2完成"); - }); - t1.start(); - t2.start(); - Thread.sleep(1000); //主线程停止1秒,保证两个线程执行完成 - System.out.println(value); -} -``` - -我们发现,value最后的值并不是我们理想的结果,有关为什么会出现这种问题,在我们学习到线程锁的时候,再来探讨。 - -*** - -## 线程的休眠和中断 - -我们前面提到,一个线程处于运行状态下,线程的下一个状态会出现以下情况: - -* 当CPU给予的运行时间结束时,会从运行状态回到就绪(可运行)状态,等待下一次获得CPU资源。 -* 当线程进入休眠 / 阻塞(如等待IO请求) / 手动调用`wait()`方法时,会使得线程处于等待状态,当等待状态结束后会回到就绪状态。 -* 当线程出现异常或错误 / 被`stop()` 方法强行停止 / 所有代码执行结束时,会使得线程的运行终止。 - -而这个部分我们着重了解一下线程的休眠和中断,首先我们来了解一下如何使得线程进如休眠状态: - -```java -public static void main(String[] args) { - Thread t = new Thread(() -> { - try { - System.out.println("l"); - Thread.sleep(1000); //sleep方法是Thread的静态方法,它只作用于当前线程(它知道当前线程是哪个) - System.out.println("b"); //调用sleep后,线程会直接进入到等待状态,直到时间结束 - } catch (InterruptedException e) { - e.printStackTrace(); - } - }); - t.start(); -} -``` - -通过调用`sleep()`方法来将当前线程进入休眠,使得线程处于等待状态一段时间。我们发现,此方法显示声明了会抛出一个InterruptedException异常,那么这个异常在什么时候会发生呢? - -```java -public static void main(String[] args) { - Thread t = new Thread(() -> { - try { - Thread.sleep(10000); //休眠10秒 - } catch (InterruptedException e) { - e.printStackTrace(); - } - }); - t.start(); - try { - Thread.sleep(3000); //休眠3秒,一定比线程t先醒来 - t.interrupt(); //调用t的interrupt方法 - } catch (InterruptedException e) { - e.printStackTrace(); - } -} -``` - -我们发现,每一个Thread对象中,都有一个`interrupt()`方法,调用此方法后,会给指定线程添加一个中断标记以告知线程需要立即停止运行或是进行其他操作,由线程来响应此中断并进行相应的处理,我们前面提到的`stop()`方法是强制终止线程,这样的做法虽然简单粗暴,但是很有可能导致资源不能完全释放,而类似这样的发送通知来告知线程需要中断,让线程自行处理后续,会更加合理一些,也是更加推荐的做法。我们来看看interrupt的用法: - -```java -public static void main(String[] args) { - Thread t = new Thread(() -> { - System.out.println("线程开始运行!"); - while (true){ //无限循环 - if(Thread.currentThread().isInterrupted()){ //判断是否存在中断标志 - break; //响应中断 - } - } - System.out.println("线程被中断了!"); - }); - t.start(); - try { - Thread.sleep(3000); //休眠3秒,一定比线程t先醒来 - t.interrupt(); //调用t的interrupt方法 - } catch (InterruptedException e) { - e.printStackTrace(); - } -} -``` - -通过`isInterrupted()`可以判断线程是否存在中断标志,如果存在,说明外部希望当前线程立即停止,也有可能是给当前线程发送一个其他的信号,如果我们并不是希望收到中断信号就是结束程序,而是通知程序做其他事情,我们可以在收到中断信号后,复位中断标记,然后继续做我们的事情: - -```java -public static void main(String[] args) { - Thread t = new Thread(() -> { - System.out.println("线程开始运行!"); - while (true){ - if(Thread.currentThread().isInterrupted()){ //判断是否存在中断标志 - System.out.println("发现中断信号,复位,继续运行..."); - Thread.interrupted(); //复位中断标记(返回值是当前是否有中断标记,这里不用管) - } - } - }); - t.start(); - try { - Thread.sleep(3000); //休眠3秒,一定比线程t先醒来 - t.interrupt(); //调用t的interrupt方法 - } catch (InterruptedException e) { - e.printStackTrace(); - } -} -``` - -复位中断标记后,会立即清除中断标记。那么,如果现在我们想暂停线程呢?我们希望线程暂时停下,比如等待其他线程执行完成后,再继续运行,那这样的操作怎么实现呢? - -```java -public static void main(String[] args) { - Thread t = new Thread(() -> { - System.out.println("线程开始运行!"); - Thread.currentThread().suspend(); //暂停此线程 - System.out.println("线程继续运行!"); - }); - t.start(); - try { - Thread.sleep(3000); //休眠3秒,一定比线程t先醒来 - t.resume(); //恢复此线程 - } catch (InterruptedException e) { - e.printStackTrace(); - } -} -``` - -虽然这样很方便地控制了线程的暂停状态,但是这两个方法我们发现实际上也是不推荐的做法,它很容易导致死锁!有关为什么被弃用的原因,我们会在线程锁继续探讨。 - -*** - -## 线程的优先级 - -实际上,Java程序中的每个线程并不是平均分配CPU时间的,为了使得线程资源分配更加合理,Java采用的是抢占式调度方式,优先级越高的线程,优先使用CPU资源!我们希望CPU花费更多的时间去处理更重要的任务,而不太重要的任务,则可以先让出一部分资源。线程的优先级一般分为以下三种: - -* MIN_PRIORITY 最低优先级 -* MAX_PRIORITY 最高优先级 -* NOM_PRIORITY 常规优先级 - -```java -public static void main(String[] args) { - Thread t = new Thread(() -> { - System.out.println("线程开始运行!"); - }); - t.start(); - t.setPriority(Thread.MIN_PRIORITY); //通过使用setPriority方法来设定优先级 -} -``` - -优先级越高的线程,获得CPU资源的概率会越大,并不是说一定优先级越高的线程越先执行! - -## 线程的礼让和加入 - -我们还可以在当前线程的工作不重要时,将CPU资源让位给其他线程,通过使用`yield()`方法来将当前资源让位给其他同优先级线程: - -```java -public static void main(String[] args) { - Thread t1 = new Thread(() -> { - System.out.println("线程1开始运行!"); - for (int i = 0; i < 50; i++) { - if(i % 5 == 0) { - System.out.println("让位!"); - Thread.yield(); - } - System.out.println("1打印:"+i); - } - System.out.println("线程1结束!"); - }); - Thread t2 = new Thread(() -> { - System.out.println("线程2开始运行!"); - for (int i = 0; i < 50; i++) { - System.out.println("2打印:"+i); - } - }); - t1.start(); - t2.start(); -} -``` - -观察结果,我们发现,在让位之后,尽可能多的在执行线程2的内容。 - -当我们希望一个线程等待另一个线程执行完成后再继续进行,我们可以使用`join()`方法来实现线程的加入: - -```java -public static void main(String[] args) { - Thread t1 = new Thread(() -> { - System.out.println("线程1开始运行!"); - for (int i = 0; i < 50; i++) { - System.out.println("1打印:"+i); - } - System.out.println("线程1结束!"); - }); - Thread t2 = new Thread(() -> { - System.out.println("线程2开始运行!"); - for (int i = 0; i < 50; i++) { - System.out.println("2打印:"+i); - if(i == 10){ - try { - System.out.println("线程1加入到此线程!"); - t1.join(); //在i==10时,让线程1加入,先完成线程1的内容,在继续当前内容 - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - } - }); - t1.start(); - t2.start(); -} -``` - -我们发现,线程1加入后,线程2等待线程1待执行的内容全部执行完成之后,再继续执行的线程2内容。注意,线程的加入只是等待另一个线程的完成,并不是将另一个线程和当前线程合并!我们来看看: - -```java -public static void main(String[] args) { - Thread t1 = new Thread(() -> { - System.out.println(Thread.currentThread().getName()+"开始运行!"); - for (int i = 0; i < 50; i++) { - System.out.println(Thread.currentThread().getName()+"打印:"+i); - } - System.out.println("线程1结束!"); - }); - Thread t2 = new Thread(() -> { - System.out.println("线程2开始运行!"); - for (int i = 0; i < 50; i++) { - System.out.println("2打印:"+i); - if(i == 10){ - try { - System.out.println("线程1加入到此线程!"); - t1.join(); //在i==10时,让线程1加入,先完成线程1的内容,在继续当前内容 - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - } - }); - t1.start(); - t2.start(); -} -``` - -实际上,t2线程只是暂时处于等待状态,当t1执行结束时,t2才开始继续执行,只是在效果上看起来好像是两个线程合并为一个线程在执行而已。 - -*** - -## 线程锁和线程同步 - -在开始讲解线程同步之前,我们需要先了解一下多线程情况下Java的内存管理: - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fvlambda.com%2Fimg%3Furl%3Dhttps%3A%2F%2Fmmbiz.qpic.cn%2Fmmbiz_png%2F2LlmEpiamhyq7hTfsoWa1GMIQlOtRuD8SScvIeB3KD7w4OoGu8wx13lBjMJLhYgYqTHND48X05m901TIEicGg49w%2F640%3Fwx_fmt%3Dpng&refer=http%3A%2F%2Fvlambda.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1637562962&t=830ccc4dbe09f2699660bfcc9a292c63) - -线程之间的共享变量(比如之前悬念中的value变量)存储在主内存(main memory)中,每个线程都有一个私有的工作内存(本地内存),工作内存中存储了该线程以读/写共享变量的副本。它类似于我们在`计算机组成原理`中学习的多处理器高速缓存机制: - -![img](https://note.youdao.com/yws/api/personal/file/WEBb1fa2c9cd0784fb19f0d8ebeb8e00976?method=download&shareKey=8d48a5816e60b026adfa21e6735b5e31) - -高速缓存通过保存内存中数据的副本来提供更加快速的数据访问,但是如果多个处理器的运算任务都涉及同一块内存区域,就可能导致各自的高速缓存数据不一致,在写回主内存时就会发生冲突,这就是引入高速缓存引发的新问题,称之为:缓存一致性。 - -实际上,Java的内存模型也是这样类似设计的,当我们同时去操作一个共享变量时,如果仅仅是读取还好,但是如果同时写入内容,就会出现问题!好比说一个银行,如果我和我的朋友同时在银行取我账户里面的钱,难道取1000还可能吐2000出来吗?我们需要一种更加安全的机制来维持秩序,保证数据的安全性! - -### 悬念破案 - -我们再来回顾一下之前留给大家的悬念: - -```java -private static int value = 0; - -public static void main(String[] args) throws InterruptedException { - Thread t1 = new Thread(() -> { - for (int i = 0; i < 10000; i++) value++; - System.out.println("线程1完成"); - }); - Thread t2 = new Thread(() -> { - for (int i = 0; i < 10000; i++) value++; - System.out.println("线程2完成"); - }); - t1.start(); - t2.start(); - Thread.sleep(1000); //主线程停止1秒,保证两个线程执行完成 - System.out.println(value); -} -``` - -实际上,当两个线程同时读取value的时候,可能会同时拿到同样的值,而进行自增操作之后,也是同样的值,再写回主内存后,本来应该进行2次自增操作,实际上只执行了一次! - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Faliyunzixunbucket.oss-cn-beijing.aliyuncs.com%2Fjpg%2F3154ff892af3cb3373a3b6b82b501a1d.jpg%3Fx-oss-process%3Dimage%2Fresize%2Cp_100%2Fauto-orient%2C1%2Fquality%2Cq_90%2Fformat%2Cjpg%2Fwatermark%2Cimage_eXVuY2VzaGk%3D%2Ct_100&refer=http%3A%2F%2Faliyunzixunbucket.oss-cn-beijing.aliyuncs.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1637565388&t=20091d33bae457edc36af7718ef1325b) - -那么要去解决这样的问题,我们就必须采取某种同步机制,来限制不同线程对于共享变量的访问!我们希望的是保证共享变量value自增操作的原子性(原子性是指一个操作或多个操作要么全部执行,且执行的过程不会被任何因素打断,包括其他线程,要么就都不执行) - -### 线程锁 - -通过synchronized关键字来创造一个线程锁,首先我们来认识一下synchronized代码块,它需要在括号中填入一个内容,必须是一个对象或是一个类,我们在value自增操作外套上同步代码块: - -```java -private static int value = 0; - -public static void main(String[] args) throws InterruptedException { - Thread t1 = new Thread(() -> { - for (int i = 0; i < 10000; i++) { - synchronized (Main.class){ - value++; - } - } - System.out.println("线程1完成"); - }); - Thread t2 = new Thread(() -> { - for (int i = 0; i < 10000; i++) { - synchronized (Main.class){ - value++; - } - } - System.out.println("线程2完成"); - }); - t1.start(); - t2.start(); - Thread.sleep(1000); //主线程停止1秒,保证两个线程执行完成 - System.out.println(value); -} -``` - -我们发现,现在得到的结果就是我们想要的内容了,因为在同步代码块执行过程中,拿到了我们传入对象或类的锁(传入的如果是对象,就是对象锁,不同的对象代表不同的对象锁,如果是类,就是类锁,类锁只有一个,实际上类锁也是对象锁,是Class类实例,但是Class类实例同样的类无论怎么获取都是同一个),但是注意两个线程必须使用同一把锁! - -当一个线程进入到同步代码块时,会获取到当前的锁,而这时如果其他使用同样的锁的同步代码块也想执行内容,就必须等待当前同步代码块的内容执行完毕,在执行完毕后会自动释放这把锁,而其他的线程才能拿到这把锁并开始执行同步代码块里面的内容。(实际上synchronized是一种悲观锁,随时都认为有其他线程在对数据进行修改,后面有机会我们还会讲到乐观锁,如CAS算法) - -那么我们来看看,如果使用的是不同对象的锁,那么还能顺利进行吗? - -```java -private static int value = 0; - -public static void main(String[] args) throws InterruptedException { - Main main1 = new Main(); - Main main2 = new Main(); - Thread t1 = new Thread(() -> { - for (int i = 0; i < 10000; i++) { - synchronized (main1){ - value++; - } - } - System.out.println("线程1完成"); - }); - Thread t2 = new Thread(() -> { - for (int i = 0; i < 10000; i++) { - synchronized (main2){ - value++; - } - } - System.out.println("线程2完成"); - }); - t1.start(); - t2.start(); - Thread.sleep(1000); //主线程停止1秒,保证两个线程执行完成 - System.out.println(value); -} -``` - -当对象不同时,获取到的是不同的锁,因此并不能保证自增操作的原子性,最后也得不到我们想要的结果。 - -synchronized关键字也可以作用于方法上,调用此方法时也会获取锁: - -```java -private static int value = 0; - -private static synchronized void add(){ - value++; -} - -public static void main(String[] args) throws InterruptedException { - Thread t1 = new Thread(() -> { - for (int i = 0; i < 10000; i++) add(); - System.out.println("线程1完成"); - }); - Thread t2 = new Thread(() -> { - for (int i = 0; i < 10000; i++) add(); - System.out.println("线程2完成"); - }); - t1.start(); - t2.start(); - Thread.sleep(1000); //主线程停止1秒,保证两个线程执行完成 - System.out.println(value); -} -``` - -我们发现实际上效果是相同的,只不过这个锁不用你去给,如果是静态方法,就是使用的类锁,而如果是普通成员方法,就是使用的对象锁。通过灵活的使用synchronized就能很好地解决我们之前提到的问题了! - -### 死锁 - -其实死锁的概念在`操作系统`中也有提及,它是指两个线程相互持有对方需要的锁,但是又迟迟不释放,导致程序卡住: - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fpic4.zhimg.com%2Fv2-9852c978350cc5e8641ba778619351bb_b.png&refer=http%3A%2F%2Fpic4.zhimg.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1637568214&t=7740dd98b8e1c4a3bfbd94a30e7f9ff8) - -我们发现,线程A和线程B都需要对方的锁,但是又被对方牢牢把握,由于线程被无限期地阻塞,因此程序不可能正常终止。我们来看看以下这段代码会得到什么结果: - -```java -public static void main(String[] args) throws InterruptedException { - Object o1 = new Object(); - Object o2 = new Object(); - Thread t1 = new Thread(() -> { - synchronized (o1){ - try { - Thread.sleep(1000); - synchronized (o2){ - System.out.println("线程1"); - } - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - }); - Thread t2 = new Thread(() -> { - synchronized (o2){ - try { - Thread.sleep(1000); - synchronized (o1){ - System.out.println("线程2"); - } - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - }); - t1.start(); - t2.start(); -} -``` - -那么我们如何去检测死锁呢?我们可以利用jstack命令来检测死锁,首先利用jps找到我们的java进程: - -```shell -nagocoler@NagodeMacBook-Pro ~ % jps -51592 Launcher -51690 Jps -14955 -51693 Main -nagocoler@NagodeMacBook-Pro ~ % jstack 51693 -... -Java stack information for the threads listed above: -=================================================== -"Thread-1": - at com.test.Main.lambda$main$1(Main.java:46) - - waiting to lock <0x000000076ad27fc0> (a java.lang.Object) - - locked <0x000000076ad27fd0> (a java.lang.Object) - at com.test.Main$$Lambda$2/1867750575.run(Unknown Source) - at java.lang.Thread.run(Thread.java:748) -"Thread-0": - at com.test.Main.lambda$main$0(Main.java:34) - - waiting to lock <0x000000076ad27fd0> (a java.lang.Object) - - locked <0x000000076ad27fc0> (a java.lang.Object) - at com.test.Main$$Lambda$1/396873410.run(Unknown Source) - at java.lang.Thread.run(Thread.java:748) - -Found 1 deadlock. -``` - -jstack自动帮助我们找到了一个死锁,并打印出了相关线程的栈追踪信息。 - -不推荐使用 `suspend() `去挂起线程的原因,是因为` suspend() `在使线程暂停的同时,并不会去释放任何锁资源。其他线程都无法访问被它占用的锁。直到对应的线程执行` resume() `方法后,被挂起的线程才能继续,从而其它被阻塞在这个锁的线程才可以继续执行。但是,如果` resume() `操作出现在` suspend() `之前执行,那么线程将一直处于挂起状态,同时一直占用锁,这就产生了死锁。 - -### wait和notify方法 - -其实我们之前可能就发现了,Object类还有三个方法我们从来没有使用过,分别是`wait()`、`notify()`以及`notifyAll()`,他们其实是需要配合synchronized来使用的,只有在同步代码块中才能使用这些方法,我们来看看他们的作用是什么: - -```java -public static void main(String[] args) throws InterruptedException { - Object o1 = new Object(); - Thread t1 = new Thread(() -> { - synchronized (o1){ - try { - System.out.println("开始等待"); - o1.wait(); //进入等待状态并释放锁 - System.out.println("等待结束!"); - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - }); - Thread t2 = new Thread(() -> { - synchronized (o1){ - System.out.println("开始唤醒!"); - o1.notify(); //唤醒处于等待状态的线程 - for (int i = 0; i < 50; i++) { - System.out.println(i); - } - //唤醒后依然需要等待这里的锁释放之前等待的线程才能继续 - } - }); - t1.start(); - Thread.sleep(1000); - t2.start(); -} -``` - -我们可以发现,对象的`wait()`方法会暂时使得此线程进入等待状态,同时会释放当前代码块持有的锁,这时其他线程可以获取到此对象的锁,当其他线程调用对象的`notify()`方法后,会唤醒刚才变成等待状态的线程(这时并没有立即释放锁)。注意,必须是在持有锁(同步代码块内部)的情况下使用,否则会抛出异常! - -notifyAll其实和notify一样,也是用于唤醒,但是前者是唤醒所有调用`wait()`后处于等待的线程,而后者是看运气随机选择一个。 - -### ThreadLocal的使用 - -既然每个线程都有一个自己的工作内存,那么能否只在自己的工作内存中创建变量仅供线程自己使用呢? - -![img](https://img2018.cnblogs.com/blog/1368768/201906/1368768-20190613220434628-1803630402.png) - -我们可以是ThreadLocal类,来创建工作内存中的变量,它将我们的变量值存储在内部(只能存储一个变量),不同的变量访问到ThreadLocal对象时,都只能获取到自己线程所属的变量。 - -```java -public static void main(String[] args) throws InterruptedException { - ThreadLocal local = new ThreadLocal<>(); //注意这是一个泛型类,存储类型为我们要存放的变量类型 - Thread t1 = new Thread(() -> { - local.set("lbwnb"); //将变量的值给予ThreadLocal - System.out.println("变量值已设定!"); - System.out.println(local.get()); //尝试获取ThreadLocal中存放的变量 - }); - Thread t2 = new Thread(() -> { - System.out.println(local.get()); //尝试获取ThreadLocal中存放的变量 - }); - t1.start(); - Thread.sleep(3000); //间隔三秒 - t2.start(); -} -``` - -上面的例子中,我们开启两个线程分别去访问ThreadLocal对象,我们发现,第一个线程存放的内容,第一个线程可以获取,但是第二个线程无法获取,我们再来看看第一个线程存入后,第二个线程也存放,是否会覆盖第一个线程存放的内容: - -```java -public static void main(String[] args) throws InterruptedException { - ThreadLocal local = new ThreadLocal<>(); //注意这是一个泛型类,存储类型为我们要存放的变量类型 - Thread t1 = new Thread(() -> { - local.set("lbwnb"); //将变量的值给予ThreadLocal - System.out.println("线程1变量值已设定!"); - try { - Thread.sleep(2000); //间隔2秒 - } catch (InterruptedException e) { - e.printStackTrace(); - } - System.out.println("线程1读取变量值:"); - System.out.println(local.get()); //尝试获取ThreadLocal中存放的变量 - }); - Thread t2 = new Thread(() -> { - local.set("yyds"); //将变量的值给予ThreadLocal - System.out.println("线程2变量值已设定!"); - }); - t1.start(); - Thread.sleep(1000); //间隔1秒 - t2.start(); -} -``` - -我们发现,即使线程2重新设定了值,也没有影响到线程1存放的值,所以说,不同线程向ThreadLocal存放数据,只会存放在线程自己的工作空间中,而不会直接存放到主内存中,因此各个线程直接存放的内容互不干扰。 - -我们发现在线程中创建的子线程,无法获得父线程工作内存中的变量: - -```java -public static void main(String[] args) { - ThreadLocal local = new ThreadLocal<>(); - Thread t = new Thread(() -> { - local.set("lbwnb"); - new Thread(() -> { - System.out.println(local.get()); - }).start(); - }); - t.start(); -} -``` - -我们可以使用InheritableThreadLocal来解决: - -```java -public static void main(String[] args) { - ThreadLocal local = new InheritableThreadLocal<>(); - Thread t = new Thread(() -> { - local.set("lbwnb"); - new Thread(() -> { - System.out.println(local.get()); - }).start(); - }); - t.start(); -} -``` - -在InheritableThreadLocal存放的内容,会自动向子线程传递。 - -*** - -## 定时器 - -我们有时候会有这样的需求,我希望定时执行任务,比如3秒后执行,其实我们可以通过使用`Thread.sleep()`来实现: - -```java -public static void main(String[] args) { - new TimerTask(() -> System.out.println("我是定时任务!"), 3000).start(); //创建并启动此定时任务 -} - -static class TimerTask{ - Runnable task; - long time; - - public TimerTask(Runnable runnable, long time){ - this.task = runnable; - this.time = time; - } - - public void start(){ - new Thread(() -> { - try { - Thread.sleep(time); - task.run(); //休眠后再运行 - } catch (InterruptedException e) { - e.printStackTrace(); - } - }).start(); - } -} -``` - -我们通过自行封装一个TimerTask类,并在启动时,先休眠3秒钟,再执行我们传入的内容。那么现在我们希望,能否循环执行一个任务呢?比如我希望每隔1秒钟执行一次代码,这样该怎么做呢? - -```java -public static void main(String[] args) { - new TimerLoopTask(() -> System.out.println("我是定时任务!"), 3000).start(); //创建并启动此定时任务 -} - -static class TimerLoopTask{ - Runnable task; - long loopTime; - - public TimerLoopTask(Runnable runnable, long loopTime){ - this.task = runnable; - this.loopTime = loopTime; - } - - public void start(){ - new Thread(() -> { - try { - while (true){ //无限循环执行 - Thread.sleep(loopTime); - task.run(); //休眠后再运行 - } - } catch (InterruptedException e) { - e.printStackTrace(); - } - }).start(); - } -} -``` - -现在我们将单次执行放入到一个无限循环中,这样就能一直执行了,并且按照我们的间隔时间进行。 - -但是终究是我们自己实现,可能很多方面还没考虑到,Java也为我们提供了一套自己的框架用于处理定时任务: - -```java -public static void main(String[] args) { - Timer timer = new Timer(); //创建定时器对象 - timer.schedule(new TimerTask() { //注意这个是一个抽象类,不是接口,无法使用lambda表达式简化,只能使用匿名内部类 - @Override - public void run() { - System.out.println(Thread.currentThread().getName()); //打印当前线程名称 - } - }, 1000); //执行一个延时任务 -} -``` - -我们可以通过创建一个Timer类来让它进行定时任务调度,我们可以通过此对象来创建任意类型的定时任务,包延时任务、循环定时任务等。我们发现,虽然任务执行完成了,但是我们的程序并没有停止,这是因为Timer内存维护了一个任务队列和一个工作线程: - -```java -public class Timer { - /** - * The timer task queue. This data structure is shared with the timer - * thread. The timer produces tasks, via its various schedule calls, - * and the timer thread consumes, executing timer tasks as appropriate, - * and removing them from the queue when they're obsolete. - */ - private final TaskQueue queue = new TaskQueue(); - - /** - * The timer thread. - */ - private final TimerThread thread = new TimerThread(queue); - - ... -} -``` - -TimerThread继承自Thread,是一个新创建的线程,在构造时自动启动: - -```java -public Timer(String name) { - thread.setName(name); - thread.start(); -} -``` - -而它的run方法会循环地读取队列中是否还有任务,如果有任务依次执行,没有的话就暂时处于休眠状态: - -```java -public void run() { - try { - mainLoop(); - } finally { - // Someone killed this Thread, behave as if Timer cancelled - synchronized(queue) { - newTasksMayBeScheduled = false; - queue.clear(); // Eliminate obsolete references - } - } -} - -/** - * The main timer loop. (See class comment.) - */ -private void mainLoop() { - try { - TimerTask task; - boolean taskFired; - synchronized(queue) { - // Wait for queue to become non-empty - while (queue.isEmpty() && newTasksMayBeScheduled) //当队列为空同时没有被关闭时,会调用wait()方法暂时处于等待状态,当有新的任务时,会被唤醒。 - queue.wait(); - if (queue.isEmpty()) - break; //当被唤醒后都没有任务时,就会结束循环,也就是结束工作线程 - ... -} -``` - -`newTasksMayBeScheduled`实际上就是标记当前定时器是否关闭,当它为false时,表示已经不会再有新的任务到来,也就是关闭,我们可以通过调用`cancel()`方法来关闭它的工作线程: - -```java -public void cancel() { - synchronized(queue) { - thread.newTasksMayBeScheduled = false; - queue.clear(); - queue.notify(); //唤醒wait使得工作线程结束 - } -} -``` - -因此,我们可以在使用完成后,调用Timer的`cancel()`方法以正常退出我们的程序: - -```java -public static void main(String[] args) { - Timer timer = new Timer(); - timer.schedule(new TimerTask() { - @Override - public void run() { - System.out.println(Thread.currentThread().getName()); - timer.cancel(); //结束 - } - }, 1000); -} -``` - -*** - -## 守护线程 - -不要把守护进程和守护线程相提并论!守护进程在后台运行运行,不需要和用户交互,本质和普通进程类似。而守护线程就不一样了,当其他所有的非守护线程结束之后,守护线程是自动结束,也就是说,Java中所有的线程都执行完毕后,守护线程自动结束,因此守护线程不适合进行IO操作,只适合打打杂: - -```java -public static void main(String[] args) throws InterruptedException{ - Thread t = new Thread(() -> { - while (true){ - try { - System.out.println("程序正常运行中..."); - Thread.sleep(1000); - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - }); - t.setDaemon(true); //设置为守护线程(必须在开始之前,中途是不允许转换的) - t.start(); - for (int i = 0; i < 5; i++) { - Thread.sleep(1000); - } -} -``` - -在守护线程中产生的新线程也是守护的: - -```java -public static void main(String[] args) throws InterruptedException{ - Thread t = new Thread(() -> { - Thread it = new Thread(() -> { - while (true){ - try { - System.out.println("程序正常运行中..."); - Thread.sleep(1000); - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - }); - it.start(); - }); - t.setDaemon(true); //设置为守护线程(必须在开始之前,中途是不允许转换的) - t.start(); - for (int i = 0; i < 5; i++) { - Thread.sleep(1000); - } -} -``` - -*** - -## 再谈集合类并行方法 - -其实我们之前在讲解集合类的根接口时,就发现有这样一个方法: - -```java -default Stream parallelStream() { - return StreamSupport.stream(spliterator(), true); -} -``` - -并行流,其实就是一个多线程执行的流,它通过默认的ForkJoinPool实现(这里不讲解原理),它可以提高你的多线程任务的速度。 - -```java -public static void main(String[] args) { - List list = new ArrayList<>(Arrays.asList(1, 4, 5, 2, 9, 3, 6, 0)); - list - .parallelStream() //获得并行流 - .forEach(i -> System.out.println(Thread.currentThread().getName()+" -> "+i)); -} -``` - -我们发现,forEach操作的顺序,并不是我们实际List中的顺序,同时每次打印也是不同的线程在执行!我们可以通过调用`forEachOrdered()`方法来使用单线程维持原本的顺序: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(Arrays.asList(1, 4, 5, 2, 9, 3, 6, 0)); - list - .parallelStream() //获得并行流 - .forEachOrdered(System.out::println); -} -``` - -我们之前还发现,在Arrays数组工具类中,也包含大量的并行方法: - -```java -public static void main(String[] args) { - int[] arr = new int[]{1, 4, 5, 2, 9, 3, 6, 0}; - Arrays.parallelSort(arr); //使用多线程进行并行排序,效率更高 - System.out.println(Arrays.toString(arr)); -} -``` - -更多地使用并行方法,可以更加充分地发挥现代计算机多核心的优势,但是同时需要注意多线程产生的异步问题! - -```java -public static void main(String[] args) { - int[] arr = new int[]{1, 4, 5, 2, 9, 3, 6, 0}; - Arrays.parallelSetAll(arr, i -> { - System.out.println(Thread.currentThread().getName()); - return arr[i]; - }); - System.out.println(Arrays.toString(arr)); -} -``` - -通过对Java多线程的了解,我们就具备了利用多线程解决问题的思维! - -*** - -## Java多线程编程实战 - -这是整个教程最后一个编程实战内容了,下一章节为`反射`一般开发者使用比较少,属于选学内容,不编排编程实战课程。 - -### 生产者与消费者 - -所谓的生产者消费者模型,是通过一个容器来解决生产者和消费者的强耦合问题。通俗的讲,就是生产者在不断的生产,消费者也在不断的消费,可是消费者消费的产品是生产者生产的,这就必然存在一个中间容器,我们可以把这个容器想象成是一个货架,当货架空的时候,生产者要生产产品,此时消费者在等待生产者往货架上生产产品,而当货架有货物的时候,消费者可以从货架上拿走商品,生产者此时等待货架出现空位,进而补货,这样不断的循环。 - -通过多线程编程,来模拟一个餐厅的2个厨师和3个顾客,假设厨师炒出一个菜的时间为3秒,顾客吃掉菜品的时间为4秒。 diff --git a/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(四).md b/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(四).md deleted file mode 100644 index 1049569..0000000 --- a/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(四).md +++ /dev/null @@ -1,1743 +0,0 @@ -# Java泛型与集合类 - -在前面我们学习了最重要的类和对象,了解了面向对象编程的思想,注意,非常重要,面向对象是必须要深入理解和掌握的内容,不能草草结束。在本章节,我们会继续深入了解,从我们的泛型开始,再到我们的数据结构,最后再开始我们的集合类学习。 - -## 走进泛型 - -为了统计学生成绩,要求设计一个Score对象,包括课程名称、课程号、课程成绩,但是成绩分为两种,一种是以`优秀、良好、合格` 来作为结果,还有一种就是 `60.0、75.5、92.5` 这样的数字分数,那么现在该如何去设计这样的一个Score类呢?现在的问题就是,成绩可能是`String`类型,也可能是`Integer`类型,如何才能很好的去存可能出现的两种类型呢? - -```java -public class Score { - String name; - String id; - Object score; //因为Object是所有类型的父类,因此既可以存放Integer也能存放String - - public Score(String name, String id, Object score) { - this.name = name; - this.id = id; - this.score = score; - } -} -``` - -以上的方法虽然很好地解决了多种类型存储问题,但是Object类型在编译阶段并不具有良好的类型判断能力,很容易出现以下的情况: - -```java -public static void main(String[] args) { - - Score score = new Score("数据结构与算法基础", "EP074512", "优秀"); //是String类型的 - - //.... - - Integer number = (Integer) score.score; //获取成绩需要进行强制类型转换,虽然并不是一开始的类型,但是编译不会报错 -} - -//运行时出现异常! -Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer - at com.test.Main.main(Main.java:14) -``` - -使用Object类型作为引用,取值只能进行强制类型转换,显然无法在编译期确定类型是否安全,项目中代码量非常之大,进行类型比较又会导致额外的开销和增加代码量,如果不经比较就很容易出现类型转换异常,代码的健壮性有所欠缺!(此方法虽然可行,但并不是最好的方法) - -为了解决以上问题,JDK1.5新增了泛型,它能够在编译阶段就检查类型安全,大大提升开发效率。 - -```java -public class Score { //将Score转变为泛型类 - String name; - String id; - T score; //T为泛型,根据用户提供的类型自动变成对应类型 - - public Score(String name, String id, T score) { //提供的score类型即为T代表的类型 - this.name = name; - this.id = id; - this.score = score; - } -} -``` - -```java -public static void main(String[] args) { - //直接确定Score的类型是字符串类型的成绩 - Score score = new Score("数据结构与算法基础", "EP074512", "优秀"); - - Integer i = score.score; //编译不通过,因为成员变量score类型被定为String! -} -``` - -泛型将数据类型的确定控制在了编译阶段,在编写代码的时候就能明确泛型的类型!如果类型不符合,将无法通过编译! - -泛型本质上也是一个语法糖(并不是JVM所支持的语法,编译后会转成编译器支持的语法,比如之前的foreach就是),在编译后会被擦除,变回上面的Object类型调用,但是类型转换由编译器帮我们完成,而不是我们自己进行转换(安全) - -```java -//反编译后的代码 -public static void main(String[] args) { - Score score = new Score("数据结构与算法基础", "EP074512", "优秀"); - String i = (String)score.score; //其实依然会变为强制类型转换,但是这是由编译器帮我们完成的 - } -``` - -像这样在编译后泛型的内容消失转变为Object的情况称为`类型擦除`(重要,需要完全理解),所以泛型只是为了方便我们在编译阶段确定类型的一种语法而已,并不是JVM所支持的。 - -综上,泛型其实就是一种类型参数,用于指定类型。 - -## 泛型的使用 - -### 泛型类 - -上一节我们已经提到泛型类的定义,实际上就是普通的类多了一个类型参数,也就是在使用时需要指定具体的泛型类型。泛型的名称一般取单个大写字母,比如T代表Type,也就是`类型`的英文单词首字母,当然也可以添加数字和其他的字符。 - -```java -public class Score { //将Score转变为泛型类 - String name; - String id; - T score; //T为泛型,根据用户提供的类型自动变成对应类型 - - public Score(String name, String id, T score) { //提供的score类型即为T代表的类型 - this.name = name; - this.id = id; - this.score = score; - } -} -``` - -在一个普通类型中定义泛型,泛型T称为`参数化类型`,在定义泛型类的引用时,需要明确指出类型: - -```java - Score score = new Score("数据结构与算法基础", "EP074512", "优秀"); -``` - -此时类中的泛型T已经被替换为String了,在我们获取此对象的泛型属性时,编译器会直接告诉我们类型: - -```java -Integer i = score.score; //编译不通过,因为成员变量score明确为String类型 -``` - -注意,泛型只能用于对象属性,也就是非静态的成员变量才能使用: - -```java -static T score; //错误,不能在静态成员上定义 -``` - -由此可见,泛型是只有在创建对象后编译器才能明确泛型类型,而静态类型是类所具有的属性,不足以使得编译器完成类型推断。 - -泛型无法使用基本类型,如果需要基本类型,只能使用基本类型的包装类进行替换! - -```java -Score score = new Score("数据结构与算法基础", "EP074512", 90.5); //编译不通过 -``` - -那么为什么泛型无法使用基本类型呢?回想上一节提到的类型擦除,其实就很好理解了。由于JVM没有泛型概念,因此泛型最后还是会被编译器编译为Object,并采用强制类型转换的形式进行类型匹配,而我们的基本数据类型和引用类型之间无法进行类型转换,所以只能使用基本类型的包装类来处理。 - -### 类的泛型方法 - -泛型方法的使用也很简单,我们只需要把它当做一个未知的类型来使用即可: - -```java -public T getScore() { //若方法的返回值类型为泛型,那么编译器会自动进行推断 - return score; -} - -public void setScore(T score) { //若方法的形式参数为泛型,那么实参只能是定义时的类型 - this.score = score; -} -``` - -```java -Score score = new Score("数据结构与算法基础", "EP074512", "优秀"); -score.setScore(10); //编译不通过,因为只接受String类型 -``` - -同样地,静态方法无法直接使用类定义的泛型(注意是无法直接使用,静态方法可以使用泛型) - -### 自定义泛型方法 - -那么如果我想在静态方法中使用泛型呢?首先我们要明确之前为什么无法使用泛型,因为之前我们的泛型定义是在类上的,只有明确具体的类型才能开始使用,也就是创建对象时完成类型确定,但是静态方法不需要依附于对象,那么只能在使用时再来确定了,所以静态方法可以使用泛型,但是需要单独定义: - -```java -public static void test(E e){ //在方法定义前声明泛型 - System.out.println(e); -} -``` - -同理,成员方法也能自行定义泛型,在实际使用时再进行类型确定: - -```java -public void test(E e){ - System.out.println(e); -} -``` - -其实,无论是泛型类还是泛型方法,再使用时一定要能够进行类型推断,明确类型才行。 - -注意一定要区分类定义的泛型和方法前定义的泛型! - -### 泛型引用 - -可以看到我们在定义一个泛型类的引用时,需要在后面指出此类型: - -```java -Score score; //声明泛型为Integer类型 -``` - -如果不希望指定类型,或是希望此引用类型可以引用任意泛型的`Score`类对象,可以使用`?`通配符,来表示自动匹配任意的可用类型: - -```java -Score score; //score可以引用任意的Score类型对象了! -``` - -那么使用通配符之后,得到的泛型成员变量会是什么类型呢? - -```java -Object o = score.getScore(); //只能变为Object -``` - -因为使用了通配符,编译器就无法进行类型推断,所以只能使用原始类型。 - -在学习了泛型的界限后,我们还会继续了解通配符的使用。 - -### 泛型的界限 - -现在有一个新的需求,现在没有String类型的成绩了,但是成绩依然可能是整数,也可能是小数,这时我们不希望用户将泛型指定为除数字类型外的其他类型,我们就需要使用到泛型的上界定义: - -```java -public class Score { //设定泛型上界,必须是Number的子类 - private final String name; - private final String id; - private T score; - - public Score(String name, String id, T score) { - this.name = name; - this.id = id; - this.score = score; - } - - public T getScore() { - return score; - } -} -``` - -通过`extends`关键字进行上界限定,只有指定类型或指定类型的子类才能作为类型参数。 - -同样的,泛型通配符也支持泛型的界限: - -```java -Score score; //限定为匹配Number及其子类的类型 -``` - -同理,既然泛型有上限,那么也有下限: - -```java -Score score; //限定为匹配Integer及其父类 -``` - -通过`super`关键字进行下界限定,只有指定类型或指定类型的父类才能作为类型参数。 - -图解如下: - -![png](http://images4.10qianwan.com/10qianwan/20191209/b_0_201912091523263309.png) - -![png](http://images4.10qianwan.com/10qianwan/20191209/b_0_201912091523264595.jpg) - -那么限定了上界后,我们再来使用这个对象的泛型成员,会变成什么类型呢? - -```java -Score score = new Score<>("数据结构与算法基础", "EP074512", 10); -Number o = score.getScore(); //得到的结果为上界类型 -``` - -也就是说,一旦我们指定了上界后,编译器就将范围从原始类型`Object`提升到我们指定的上界`Number`,但是依然无法明确具体类型。思考:那如果定义下限呢? - -那么既然我们可以给泛型类限定上界,现在我们来看编译后结果呢: - -```java -//使用javap -l 进行反编译 -public class com.test.Score { - public com.test.Score(java.lang.String, java.lang.String, T); - LineNumberTable: - line 8: 0 - line 9: 4 - line 10: 9 - line 11: 14 - line 12: 19 - LocalVariableTable: - Start Length Slot Name Signature - 0 20 0 this Lcom/test/Score; - 0 20 1 name Ljava/lang/String; - 0 20 2 id Ljava/lang/String; - 0 20 3 score Ljava/lang/Number; //可以看到score的类型直接被编译为Number类 - - public T getScore(); - LineNumberTable: - line 15: 0 - LocalVariableTable: - Start Length Slot Name Signature - 0 5 0 this Lcom/test/Score; -} - -``` - -因此,一旦确立上限后,编译器会自动将类型提升到上限类型。 - -### 钻石运算符 - -我们发现,每次创建泛型对象都需要在前后都标明类型,但是实际上后面的类型声明是可以去掉的,因为我们在传入参数时或定义泛型类的引用时,就已经明确了类型,因此JDK1.7提供了钻石运算符来简化代码: - -```java -Score score = new Score("数据结构与算法基础", "EP074512", 10); //1.7之前 - -Score score = new Score<>("数据结构与算法基础", "EP074512", 10); //1.7之后 -``` - -### 泛型与多态 - -泛型不仅仅可以可以定义在类上,同时也能定义在接口上: - -```java -public interface ScoreInterface { - T getScore(); - void setScore(T t); -} -``` - -当实现此接口时,我们可以选择在实现类明确泛型类型或是继续使用此泛型,让具体创建的对象来确定类型。 - -```java -public class Score implements ScoreInterface{ //将Score转变为泛型类 - private final String name; - private final String id; - private T score; - - public Score(String name, String id, T score) { - this.name = name; - this.id = id; - this.score = score; - } - - public T getScore() { - return score; - } - - @Override - public void setScore(T score) { - this.score = score; - } -} -``` - -```java -public class StringScore implements ScoreInterface{ //在实现时明确类型 - - @Override - public String getScore() { - return null; - } - - @Override - public void setScore(String s) { - - } -} -``` - -抽象类同理,这里就不多做演示了。 - -### 多态类型擦除 - -思考一个问题,既然继承后明确了泛型类型,那么为什么`@Override`不会出现错误呢,重写的条件是需要和父类的返回值类型、形式参数一致,而泛型默认的原始类型是Object类型,子类明确后变为Number类型,这显然不满足重写的条件,但是为什么依然能编译通过呢? - -```java -class A{ - private T t; - public T get(){ - return t; - } - public void set(T t){ - this.t=t; - } -} - -class B extends A{ - private Number n; - - @Override - public Number get(){ //这并不满足重写的要求,因为只能重写父类同样返回值和参数的方法,但是这样却能够通过编译! - return t; - } - - @Override - public void set(Number t){ - this.t=t; - } -} -``` - -通过反编译进行观察,实际上是编译器帮助我们生成了两个桥接方法用于支持重写: - -```java -@Override -public Object get(){ - return this.get();//调用返回Number的那个方法 -} - -@Override -public void set(Object t ){ - this.set((Number)t ); //调用参数是Number的那个方法 -} -``` - -*** - -## 数据结构基础 - -警告!本章最难的部分! - -学习集合类之前,我们还有最关键的内容需要学习,同第一章一样,自底向上才是最佳的学习方向,比起直接带大家认识集合类,不如先了解一下数据结构,只有了解了数据结构基础,才能更好地学习集合类,同时,数据结构也是你以后深入学习JDK源码的必备条件!(学习不要快餐式!)当然,我们主要是讲解Java,数据结构作为铺垫作用,所以我们只会讲解关键的部分,其他部分可以下去自行了解。 - -在计算机科学中,数据结构是一种数据组织、管理和存储的格式,它可以帮助我们实现对数据高效的访问和修改。更准确地说,数据结构是数据值的集合,可以体现数据值之间的关系,以及可以对数据进行应用的函数或操作。 - -通俗地说,我们需要去学习在计算机中如何去更好地管理我们的数据,才能让我们对我们的数据控制更加灵活! - -### 线性表 - -线性表是最基本的一种数据结构,它是表示一组相同类型数据的有限序列,你可以把它与数组进行参考,但是它并不是数组,线性表是一种表结构,它能够支持数据的插入、删除、更新、查询等,同时数组可以随意存放在数组中任意位置,而线性表只能依次有序排列,不能出现空隙,因此,我们需要进一步的设计。 - -#### 顺序表 - -将数据依次存储在连续的整块物理空间中,这种存储结构称为`顺序存储结构`,而以这种方式实现的线性表,我们称为`顺序表`。 - -同样的,表中的每一个个体都被称为`元素`,元素左边的元素(上一个元素),称为`前驱`,同理,右边的元素(后一个元素)称为`后驱`。 - -![img](https://img1.baidu.com/it/u=4003060195,523881164&fm=26&fmt=auto) - -我们设计线性表的目标就是为了去更好地管理我们的数据,也就是说,我们可以基于数组,来进行封装,实现增删改查!既然要存储一组数据,那么很容易联想到我们之前学过的数组,数组就能够容纳一组同类型的数据。 - -目标:以数组为底层,编写以下抽象类的具体实现 - -```java -/** - * 线性表抽象类 - * @param 存储的元素(Element)类型 - */ -public abstract class AbstractList { - /** - * 获取表的长度 - * @return 顺序表的长度 - */ - public abstract int size(); - - /** - * 添加一个元素 - * @param e 元素 - * @param index 要添加的位置(索引) - */ - public abstract void add(E e, int index); - - /** - * 移除指定位置的元素 - * @param index 位置 - * @return 移除的元素 - */ - public abstract E remove(int index); - - /** - * 获取指定位置的元素 - * @param index 位置 - * @return 元素 - */ - public abstract E get(int index); -} -``` - -#### 链表 - -数据分散的存储在物理空间中,通过一根线保存着它们之间的逻辑关系,这种存储结构称为`链式存储结构` - -实际上,就是每一个结点存放一个元素和一个指向下一个结点的引用(C语言里面是指针,Java中就是对象的引用,代表下一个结点对象) - -![img](https://img1.baidu.com/it/u=3381038214,3369355034&fm=26&fmt=auto&gp=0.jpg) - -利用这种思想,我们再来尝试实现上面的抽象类,从实际的代码中感受! - -比较:顺序表和链表的优异? - -顺序表优缺点: - -* 访问速度快,随机访问性能高 -* 插入和删除的效率低下,极端情况下需要变更整个表 -* 不易扩充,需要复制并重新创建数组 - -链表优缺点: - -* 插入和删除效率高,只需要改变连接点的指向即可 -* 动态扩充容量,无需担心容量问题 -* 访问元素需要依次寻找,随机访问元素效率低下 - -链表只能指向后面,能不能指向前面呢?双向链表! - -*** - -栈和队列实际上就是对线性表加以约束的一种数据结构,如果前面的线性表的掌握已经ok,那么栈和队列就非常轻松了! - -#### 栈 - -栈遵循先入后出原则,只能在线性表的一端添加和删除元素。我们可以把栈看做一个杯子,杯子只有一个口进出,最低处的元素只能等到上面的元素离开杯子后,才能离开。 - -![img](https://img2.baidu.com/it/u=4172728777,3669222584&fm=26&fmt=auto&gp=0.jpg) - -向栈中插入一个元素时,称为`入栈(压栈)`,移除栈顶元素称为`出栈`,我们需要尝试实现以下抽象类型: - -```java -/** - * 抽象类型栈,待实现 - * @param 元素类型 - */ -public abstract class AbstractStack { - - /** - * 出栈操作 - * @return 栈顶元素 - */ - public abstract E pop(); - - /** - * 入栈操作 - * @param e 元素 - */ - public abstract void push(E e); -} -``` - -其实,我们的JVM在处理方法调用时,也是一个栈操作: - -![img](https://img0.baidu.com/it/u=1098322354,1667908648&fm=26&fmt=auto) - -所以说,如果玩不好递归,就会像这样: - -```java -public class Main { - public static void main(String[] args) { - go(); - } - - private static void go(){ - go(); - } -} - -Exception in thread "main" java.lang.StackOverflowError - at com.test.Main.go(Main.java:13) - at com.test.Main.go(Main.java:13) - at com.test.Main.go(Main.java:13) - at com.test.Main.go(Main.java:13) - at com.test.Main.go(Main.java:13) - at com.test.Main.go(Main.java:13) - at com.test.Main.go(Main.java:13) - at com.test.Main.go(Main.java:13) - ... -``` - -栈的深度是有限制的,如果达到限制,将会出现`StackOverflowError`错误(注意是错误!说明是JVM出现了问题) - -#### 队列 - -队列同样也是受限制的线性表,不过队列就像我们排队一样,只能从队尾开始排,从队首出。 - -![img](https://img1.baidu.com/it/u=2682903513,371531599&fm=26&fmt=auto) - -所以我们要实现以下内容: - -```java - -/** - * - * @param - */ -public abstract class AbstractQueue { - - /** - * 进队操作 - * @param e 元素 - */ - public abstract void offer(E e); - - /** - * 出队操作 - * @return 元素 - */ - public abstract E poll(); -} - -``` - -*** - -### 二叉树 - -本版块主要学习的是二叉树,树也是一种数据结构,但是它使用起来更加的复杂。 - -#### 树 - -我们前面已经学习过链表了,我们知道链表是单个结点之间相连,也就是一种一对一的关系,而树则是一个结点连接多个结点,也就是一对多的关系。 - -![img](https://img2.baidu.com/it/u=1603039729,2366298993&fm=26&fmt=auto) - -一个结点可以有N个子结点,就像上图一样,看起来就像是一棵树。而位于最顶端的结点(没有父结点)我们称为`根结点`,而结点拥有的子节点数量称为`度`,每向下一级称为一个`层次`,树中出现的最大层次称为树的`深度(高度)`。 - -#### 二叉树 - -二叉树是一种特殊的树,每个结点最多有两颗子树,所以二叉树中不存在度大于2的结点,位于两边的子结点称为左右子树(注意,左右子树是明确区分的,是左就是左,是右就是右) - -![img](https://img1.baidu.com/it/u=4097712510,2021128931&fm=26&fmt=auto&gp=0.jpg) - -数学性质: - -* 在二叉树的第i层上最多有2^(i-1) 个节点。 -* 二叉树中如果深度为k,那么最多有2^k-1个节点。 - -设计一个二叉树结点类: - -```java -public class TreeNode { - public E e; //当前结点数据 - public TreeNode left; //左子树 - public TreeNode right; //右子树 -} -``` - -#### 二叉树的遍历 - -顺序表的遍历其实就是依次有序去访问表中每一个元素,而像二叉树这样的复杂结构,我们有四种遍历方式,他们是:前序遍历、中序遍历、后序遍历以及层序遍历,本版块我们主要讨论前三种遍历方式: - -* **前序遍历**:从二叉树的根结点出发,到达结点时就直接输出结点数据,按照先向左在向右的方向访问。ABCDEF -* **中序遍历**:从二叉树的根结点出发,优先输出左子树的节点的数据,再输出当前节点本身,最后才是右子树。CBDAEF -* **后序遍历**:从二叉树的根结点出发,优先遍历其左子树,再遍历右子树,最后在输出当前节点本身。CDBFEA - -#### 满二叉树和完全二叉树 - -满二叉树和完全二叉树其实就是特殊情况下的二叉树,满二叉树左右的所有叶子节点都在同一层,也就是说,完全把每一个层级都给加满了结点。完全二叉树与满二叉树不同的地方在于,它的最下层叶子节点可以不满,但是最下层的叶子节点必须靠左排布。 - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fwww.examw.com%2Fncre%2FFiles%2F2011-6%2F20%2F93236613.gif&refer=http%3A%2F%2Fwww.examw.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1634373420&t=19f3ed8195b15d0b8f49201cc2803759) - -其实满二叉树和完全二叉树就是有一定规律的二叉树,很容易理解。 - -### 快速查找 - -我们之前提到的这些数据结构,很好地帮我们管理了数据,但是,如果需要查找某一个元素是否存在于数据结构中,如何才能更加高效的去完成呢? - -#### 哈希表 - -通过前面的学习,我们发现,顺序表虽然查询效率高,但是插入删除有严重表更新的问题,而链表虽然弥补了更新问题,但是查询效率实在是太低了,能否有一种折中方案?哈希表! - -不知大家在之前的学习中是否发现,我们的Object类中,定义了一个叫做`hashcode()`的方法?而这个方法呢,就是为了更好地支持哈希表的实现。`hashcode()`默认得到的是对象的内存地址,也就是说,每个对象的hashCode都不一样。 - -哈希表,其实本质上就是一个存放链表的数组,那么它是如何去存储数据的呢?我们先来看看长啥样: - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg2020.cnblogs.com%2Fblog%2F2127470%2F202012%2F2127470-20201222194727385-1606433879.jpg&refer=http%3A%2F%2Fimg2020.cnblogs.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1634376519&t=8cc6fc7a35e695cc5ba12687974daa54) - -数组中每一个元素都是一个头结点,用于保存数据,那我们怎么确定数据应该放在哪一个位置呢?通过hash算法,我们能够瞬间得到元素应该放置的位置。 - -```java -//假设hash表长度为16,hash算法为: -private int hash(int hashcode){ - return hashcode % 16; -} -``` - -设想这样一个问题,如果计算出来的hash值和之前已经存在的元素相同了呢?这种情况我们称为`hash碰撞`,这也是为什么要将每一个表元素设置为一个链表的头结点的原因,一旦发现重复,我们可以往后继续添加节点。 - -当然,以上的hash表结构只是一种设计方案,在面对大额数据时,是不够用的,在JDK1.8中,集合类使用的是数组+二叉树的形式解决的(这里的二叉树是经过加强的二叉树,不是前面讲得简单二叉树,我们下一节就会开始讲) - -#### 二叉排序树 - -我们前面学习的二叉树效率是不够的,我们需要的是一种效率更高的二叉树,因此,基于二叉树的改进,提出了二叉查找树,可以看到结构像下面这样: - -![img](https://img0.baidu.com/it/u=3674232536,1832030468&fm=26&fmt=auto&gp=0.jpg) - -不难发现,每个节点的左子树,一定小于当前节点的值,每个节点的右子树,一定大于当前节点的值,这样的二叉树称为`二叉排序树`。利用二分搜索的思想,我们就可以快速查找某个节点! - -#### 平衡二叉树 - -在了解了二叉查找树之后,我们发现,如果根节点为10,现在加入到结点的值从9开始,依次减小到1,那么这个表就会很奇怪,就像下面这样: - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg-blog.csdnimg.cn%2F20191127151205330.png%3Fx-oss-process%3Dimage%2Fwatermark%2Ctype_ZmFuZ3poZW5naGVpdGk%2Cshadow_10%2Ctext_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzQzNDE5MTA1%2Csize_16%2Ccolor_FFFFFF%2Ct_70&refer=http%3A%2F%2Fimg-blog.csdnimg.cn&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1634378465&t=eb9bf93cfb9191362d1170b93b06d902) - -显然,当所有的结点都排列到一边,这种情况下,查找效率会直接退化为最原始的二叉树!因此我们需要维持二叉树的平衡,才能维持原有的查找效率。 - -现在我们对二叉排序树加以约束,要求每个结点的左右两个子树的高度差的绝对值不超过1,这样的二叉树称为`平衡二叉树`,同时要求每个结点的左右子树都是平衡二叉树,这样,就不会因为一边的疯狂增加导致失衡。我们来看看以下几种情况: - -![img](https://pic002.cnblogs.com/images/2012/214741/2012072218213884.png) - -左左失衡 - -![img](https://pic002.cnblogs.com/images/2012/214741/2012072218444051.png) - -右右失衡 - -![img](https://pic002.cnblogs.com/images/2012/214741/2012072219144367.png) - -左右失衡 - -![img](https://pic002.cnblogs.com/images/2012/214741/2012072219540371.png) - -右左失衡 - -通过以上四种情况的处理,最终得到维护平衡二叉树的算法。 - -#### 红黑树 - -红黑树也是二叉排序树的一种改进,同平衡二叉树一样,红黑树也是一种维护平衡的二叉排序树,但是没有平衡二叉树那样严格(平衡二叉树每次插入新结点时,可能会出现大量的旋转,而红黑树保证不超过三次),红黑树降低了对于旋转的要求,因此效率有一定的提升同时实现起来也更加简单。但是红黑树的效率却高于平衡二叉树,红黑树也是JDK1.8中使用的数据结构! - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg2018.cnblogs.com%2Fblog%2F1301290%2F201904%2F1301290-20190418213139526-1239863354.jpg&refer=http%3A%2F%2Fimg2018.cnblogs.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1634381313&t=d60b654d81ee3930b8518f194c976409) - -红黑树的特性: -(1)每个节点或者是黑色,或者是红色。 -(2)根节点是黑色。 -(3)每个叶子节点的两边也需要表示(虽然没有,但是null也需要表示出来)是黑色。 -(4)如果一个节点是红色的,则它的子节点必须是黑色的。 -(5)从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。 - -我们来看看一个节点,是如何插入到红黑树中的: - -基本的 插入规则和平衡二叉树一样,但是在插入后: - -1. 将新插入的节点标记为红色 -2. 如果 X 是根结点(root),则标记为黑色 -3. 如果 X 的 parent 不是黑色,同时 X 也不是 root: - -- 3.1 如果 X 的 uncle (叔叔) 是红色 - -- - 3.1.1 将 parent 和 uncle 标记为黑色 - - 3.1.2 将 grand parent (祖父) 标记为红色 - - 3.1.3 让 X 节点的颜色与 X 祖父的颜色相同,然后重复步骤 2、3 - -- 3.2 如果 X 的 uncle (叔叔) 是黑色,我们要分四种情况处理 - -- - 3.2.1 左左 (P 是 G 的左孩子,并且 X 是 P 的左孩子) - - 3.2.2 左右 (P 是 G 的左孩子,并且 X 是 P 的右孩子) - - 3.2.3 右右 (P 是 G 的右孩子,并且 X 是 P 的右孩子) - - 3.2.4 右左 (P 是 G 的右孩子,并且 X 是 P 的左孩子) - - 其实这种情况下处理就和我们的平衡二叉树一样了 - -*** - -## 认识集合类 - -集合表示一组对象,称为其元素。一些集合允许重复的元素,而另一些则不允许。一些集合是有序的,而其他则是无序的。 - -集合类其实就是为了更好地组织、管理和操作我们的数据而存在的,包括列表、集合、队列、映射等数据结构。从这一块开始,我们会从源码角度给大家讲解(数据结构很重要!),不仅仅是教会大家如何去使用。 - -集合类最顶层不是抽象类而是接口,因为接口代表的是某个功能,而抽象类是已经快要成形的类型,不同的集合类的底层实现是不相同的,同时一个集合类可能会同时具有两种及以上功能(既能做队列也能做列表),所以采用接口会更加合适,接口只需定义支持的功能即可。 - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fwww.mianfeiwendang.com%2Fpic%2F29a5b61e9e5e19fe10103b4c%2F1-356-jpg_6_0_______-858-0-0-858.jpg&refer=http%3A%2F%2Fwww.mianfeiwendang.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1634434848&t=e696ab71af584ef08a38fc328956586c) - -### 数组与集合 - -相同之处: - -1. 它们都是容器,都能够容纳一组元素。 - -不同之处: - -1. 数组的大小是固定的,集合的大小是可变的。 -2. 数组可以存放基本数据类型,但集合只能存放对象。 -3. 数组存放的类型只能是一种,但集合可以有不同种类的元素。 - -### 集合根接口Collection - -本接口中定义了全部的集合基本操作,我们可以在源码中看看。 - -我们再来看看List和Set以及Queue接口。 - -## 集合类的使用 - -### List列表 - -首先介绍ArrayList,它的底层是用数组实现的,内部维护的是一个可改变大小的数组,也就是我们之前所说的线性表!跟我们之前自己写的ArrayList相比,它更加的规范,同时继承自List接口。 - -先看看ArrayList的源码! - -#### 基本操作 - -```java -List list = new ArrayList<>(); //默认长度的列表 -List listInit = new ArrayList<>(100); //初始长度为100的列表 -``` - -向列表中添加元素: - -```java -List list = new ArrayList<>(); -list.add("lbwnb"); -list.add("yyds"); -list.contains("yyds"); //是否包含某个元素 -System.out.println(list); -``` - -移除元素: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(); - list.add("lbwnb"); - list.add("yyds"); - list.remove(0); //按下标移除元素 - list.remove("yyds"); //移除指定元素 - System.out.println(list); -} -``` - -也支持批量操作: - -```java -public static void main(String[] args) { - ArrayList list = new ArrayList<>(); - list.addAll(new ArrayList<>()); //在尾部批量添加元素 - list.removeAll(new ArrayList<>()); //批量移除元素(只有给定集合中存在的元素才会被移除) - list.retainAll(new ArrayList<>()); //只保留某些元素 - System.out.println(list); -} -``` - -我们再来看LinkedList,其实本质就是一个链表!我们来看看源码。 - -其实与我们之前编写的LinkedList不同之处在于,它内部使用的是一个双向链表: - -```java -private static class Node { - E item; - Node next; - Node prev; - - Node(Node prev, E element, Node next) { - this.item = element; - this.next = next; - this.prev = prev; - } -} -``` - -当然,我们发现它还实现了Queue接口,所以LinkedList也能被当做一个队列或是栈来使用。 - -```java -public static void main(String[] args) { - LinkedList list = new LinkedList<>(); - list.offer("A"); //入队 - System.out.println(list.poll()); //出队 - list.push("A"); - list.push("B"); //进栈 - list.push("C"); - System.out.println(list.pop()); - System.out.println(list.pop()); //出栈 - System.out.println(list.pop()); -} -``` - -#### 利用代码块来快速添加内容 - -前面我们学习了匿名内部类,我们就可以利用代码块,来快速生成一个自带元素的List - -```java -List list = new LinkedList(){{ //初始化时添加 - this.add("A"); - this.add("B"); -}}; -``` - -如果是需要快速生成一个只读的List,后面我们会讲解Arrays工具类。 - -#### 集合的排序 - -```java -List list = new LinkedList(){ //Java9才支持匿名内部类使用钻石运算符 - { - this.add(10); - this.add(2); - this.add(5); - this.add(8); - } -}; -list.sort((a, b) -> { //排序已经由JDK实现,现在只需要填入自定义规则,完成Comparator接口实现 - return a - b; //返回值小于0,表示a应该在b前面,返回值大于0,表示b应该在a后面,等于0则不进行交换 -}); -System.out.println(list); -``` - -### 迭代器 - -#### 集合的遍历 - -所有的集合类,都支持foreach循环! - -```java -public static void main(String[] args) { - List list = new LinkedList(){ //Java9才支持匿名内部类使用钻石运算符 - { - this.add(10); - this.add(2); - this.add(5); - this.add(8); - } - }; - for (Integer integer : list) { - System.out.println(integer); - } -} -``` - -当然,也可以使用JDK1.8新增的forEach方法,它接受一个Consumer接口实现: - -```java -list.forEach(i -> { - System.out.println(i); -}); -``` - -从JDK1.8开始,lambda表达式开始逐渐成为主流,我们需要去适应函数式编程的这种语法,包括批量替换,也是用到了函数式接口来完成的。 - -```java -list.replaceAll((i) -> { - if(i == 2) return 3; //将所有的2替换为3 - else return i; //不是2就不变 -}); -System.out.println(list); -``` - -#### Iterable和Iterator接口 - -我们之前学习数据结构时,已经得知,不同的线性表实现,在获取元素时的效率也不同,因此我们需要一种更好地方式来统一不同数据结构的遍历。 - -由于ArrayList对于随机访问的速度更快,而LinkedList对于顺序访问的速度更快,因此在上述的传统for循环遍历操作中,ArrayList的效率更胜一筹,因此我们要使得LinkedList遍历效率提升,就需要采用顺序访问的方式进行遍历,如果没有迭代器帮助我们统一标准,那么我们在应对多种集合类型的时候,就需要对应编写不同的遍历算法,很显然这样会降低我们的开发效率,而迭代器的出现就帮助我们解决了这个问题。 - -我们先来看看迭代器里面方法: - -```java -public interface Iterator { - //... -} -``` - -每个集合类都有自己的迭代器,通过`iterator()`方法来获取: - -```java -Iterator iterator = list.iterator(); //生成一个新的迭代器 -while (iterator.hasNext()){ //判断是否还有下一个元素 - Integer i = iterator.next(); //获取下一个元素(获取一个少一个) - System.out.println(i); -} -``` - -迭代器生成后,默认指向第一个元素,每次调用`next()`方法,都会将指针后移,当指针移动到最后一个元素之后,调用`hasNext()`将会返回`false`,迭代器是一次性的,用完即止,如果需要再次使用,需要调用`iterator()`方法。 - -```java -ListIterator iterator = list.listIterator(); //List还有一个更好地迭代器实现ListIterator -``` - -`ListIterator`是List中独有的迭代器,在原有迭代器基础上新增了一些额外的操作。 - -*** - -### Set集合 - -我们之前已经看过`Set`接口的定义了,我们发现接口中定义的方法都是Collection中直接继承的,因此,Set支持的功能其实也就和Collection中定义的差不多,只不过使用方法上稍有不同。 - -Set集合特点: - -* 不允许出现重复元素 -* 不支持随机访问(不允许通过下标访问) - -首先认识一下HashSet,它的底层就是采用哈希表实现的(我们在这里先不去探讨实现原理,因为底层实质上维护的是一个HashMap,我们学习了Map之后再来讨论) - -```java -public static void main(String[] args) { - HashSet set = new HashSet<>(); - set.add(120); //支持插入元素,但是不支持指定位置插入 - set.add(13); - set.add(11); - for (Integer integer : set) { - System.out.println(integer); - } -} -``` - -运行上面代码发现,最后Set集合中存在的元素顺序,并不是我们的插入顺序,这是因为HashSet底层是采用`哈希表`来实现的,实际的存放顺序是由Hash算法决定的。 - -那么我们希望数据按照我们插入的顺序进行保存该怎么办呢?我们可以使用LinkedHashSet: - -```java -public static void main(String[] args) { - LinkedHashSet set = new LinkedHashSet<>(); //会自动保存我们的插入顺序 - set.add(120); - set.add(13); - set.add(11); - for (Integer integer : set) { - System.out.println(integer); - } -} -``` - -LinkedHashSet底层维护的不再是一个HashMap,而是LinkedHashMap,它能够在插入数据时利用链表自动维护顺序,因此这样就能够保证我们插入顺序和最后的迭代顺序一致了。 - -还有一种Set叫做TreeSet,它会在元素插入时进行排序: - -```java -public static void main(String[] args) { - TreeSet set = new TreeSet<>(); - set.add(1); - set.add(3); - set.add(2); - System.out.println(set); -} -``` - -可以看到最后得到的结果并不是我们插入顺序,而是按照数字的大小进行排列。当然,我们也可以自定义排序规则: - -```java -public static void main(String[] args) { - TreeSet set = new TreeSet<>((a, b) -> b - a); //在创建对象时指定规则即可 - set.add(1); - set.add(3); - set.add(2); - System.out.println(set); -} -``` - -现在的结果就是我们自定义的排序规则了。 - -虽然Set集合只是粗略的进行了讲解,但是学习Map之后,我们还会回来看我们Set的底层实现,所以说最重要的还是Map。本节只需要记住Set的性质、使用即可。 - -*** - -### Map映射 - -#### 什么是映射 - -我们在高中阶段其实已经学习过映射了,映射指两个元素的之间相互“对应”的关系,也就是说,我们的元素之间是两两对应的,是以键值对的形式存在。 - -![映射](https://bkimg.cdn.bcebos.com/pic/7aec54e736d12f2e89cbcbb64dc2d5628435681d?x-bce-process=image/resize,m_lfit,w_268,limit_1/format,f_jpg) - -#### Map接口 - -Map就是为了实现这种数据结构而存在的,我们通过保存键值对的形式来存储映射关系。 - -我们先来看看Map接口中定义了哪些操作。 - -#### HashMap和LinkedHashMap - -HashMap的实现过程,相比List,就非常地复杂了,它并不是简简单单的表结构,而是利用哈希表存放映射关系,我们来看看HashMap是如何实现的,首先回顾我们之前学习的哈希表,它长这样: - -![img](https://upload-images.jianshu.io/upload_images/16566539-672ab962ae6dc500.png?imageMogr2/auto-orient/strip|imageView2/2/w/508/format/webp) - -哈希表的本质其实就是一个用于存放后续节点的头结点的数组,数组里面的每一个元素都是一个头结点(也可以说就是一个链表),当要新插入一个数据时,会先计算该数据的哈希值,找到数组下标,然后创建一个新的节点,添加到对应的链表后面。 - -而HashMap就是采用的这种方式,我们可以看到源码中同样定义了这样的一个结构: - -```java -/** - * The table, initialized on first use, and resized as - * necessary. When allocated, length is always a power of two. - * (We also tolerate length zero in some operations to allow - * bootstrapping mechanics that are currently not needed.) - */ -transient Node[] table; -``` - -这个表会在第一次使用时初始化,同时在必要时进行扩容,并且它的大小永远是2的倍数! - -```java -/** - * The default initial capacity - MUST be a power of two. - */ -static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 -``` - -我们可以看到默认的大小为2的4次方,每次都需要是2的倍数,也就是说,下一次增长之后,大小会变成2的5次方。 - -我们现在需要思考一个问题,当我们表中的数据不断增加之后,链表会变得越来越长,这样会严重导致查询速度变慢,首先想到办法就是,我们可以对数组的长度进行扩容,来存放更多的链表,那么什么情况下会进行扩容呢? - -```java -/** - * The load factor for the hash table. - * - * @serial - */ -final float loadFactor; -``` - -我们还发现HashMap源码中有这样一个变量,也就是`负载因子`,那么它是干嘛的呢? - -负载因子其实就是用来衡量当前情况是否需要进行扩容的标准。我们可以看到默认的负载因子是`0.75` - -```java -/** - * The load factor used when none specified in constructor. - */ -static final float DEFAULT_LOAD_FACTOR = 0.75f; -``` - -那么负载因子是怎么控制扩容的呢?`0.75`的意思是,在插入新的结点后,如果当前数组的占用率达到75%则进行扩容。在扩容时,会将所有的数据,重新计算哈希值,得到一个新的下标,组成新的哈希表。 - -但是这样依然有一个问题,链表过长的情况还是有可能发生,所以,为了从根源上解决这个问题,在JDK1.8时,引入了红黑树这个数据结构。 - -![](https://i0.hdslb.com/bfs/album/5884577601a5ab1aabe10ee95696557b8d3b5338.jpg) - -当链表的长度达到8时,会自动将链表转换为红黑树,这样能使得原有的查询效率大幅度降低!当使用红黑树之后,我们就可以利用二分搜索的思想,快速地去寻找我们想要的结果,而不是像链表一样挨个去看。 - -```java -/** - * Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn - * extends Node) so can be used as extension of either regular or - * linked node. - */ -static final class TreeNode extends LinkedHashMap.Entry { -``` - -除了Node以外,HashMap还有TreeNode,很明显这就是为了实现红黑树而设计的内部类。不过我们发现,TreeNode并不是直接继承Node,而是使用了LinkedHashMap中的Entry实现,它保存了前后节点的顺序(也就是我们的插入顺序)。 - -```java -/** - * HashMap.Node subclass for normal LinkedHashMap entries. - */ -static class Entry extends HashMap.Node { - Entry before, after; - Entry(int hash, K key, V value, Node next) { - super(hash, key, value, next); - } -} -``` - -LinkedHashMap是直接继承自HashMap,具有HashMap的全部性质,同时得益于每一个节点都是一个双向链表,保存了插入顺序,这样我们在遍历LinkedHashMap时,顺序就同我们的插入顺序一致。当然,也可以使用访问顺序,也就是说对于刚访问过的元素,会被排到最后一位。 - -```java -public static void main(String[] args) { - LinkedHashMap map = new LinkedHashMap<>(16, 0.75f, true); //以访问顺序 - map.put(1, "A"); - map.put(2, "B"); - map.put(3, "C"); - map.get(2); - System.out.println(map); -} -``` - -观察结果,我们发现,刚访问的结果被排到了最后一位。 - -#### TreeMap - -TreeMap其实就是自动维护顺序的一种Map,就和我们前面提到的TreeSet一样: - -```java -/** - * The comparator used to maintain order in this tree map, or - * null if it uses the natural ordering of its keys. - * - * @serial - */ -private final Comparator comparator; - -private transient Entry root; - -/** -* Node in the Tree. Doubles as a means to pass key-value pairs back to -* user (see Map.Entry). -*/ - -static final class Entry implements Map.Entry { -``` - -我们发现它的内部直接维护了一个红黑树,就像它的名字一样,就是一个Tree,因为它默认就是有序的,所以说直接采用红黑树会更好。我们在创建时,直接给予一个比较规则即可。 - -#### Map的使用 - -我们首先来看看Map的一些基本操作: - -```java -public static void main(String[] args) { - Map map = new HashMap<>(); - map.put(1, "A"); - map.put(2, "B"); - map.put(3, "C"); - System.out.println(map.get(1)); //获取Key为1的值 - System.out.println(map.getOrDefault(0, "K")); //不存在就返回K - map.remove(1); //移除这个Key的键值对 -} -``` - -由于Map并未实现迭代器接口,因此不支持foreach,但是JDK1.8为我们提供了forEach方法使用: - -```java -public static void main(String[] args) { - Map map = new HashMap<>(); - map.put(1, "A"); - map.put(2, "B"); - map.put(3, "C"); - map.forEach((k, v) -> System.out.println(k+"->"+v)); - - for (Map.Entry entry : map.entrySet()) { //也可以获取所有的Entry来foreach - int key = entry.getKey(); - String value = entry.getValue(); - System.out.println(key+" -> "+value); - } -} -``` - -我们也可以单独获取所有的值或者是键: - -```java -public static void main(String[] args) { - Map map = new HashMap<>(); - map.put(1, "A"); - map.put(2, "B"); - map.put(3, "C"); - System.out.println(map.keySet()); //直接获取所有的key - System.out.println(map.values()); //直接获取所有的值 -} -``` - -#### 再谈Set原理 - -通过观察HashSet的源码发现,HashSet几乎都在操作内部维护的一个HashMap,也就是说,HashSet只是一个表壳,而内部维护的HashMap才是灵魂! - -```java -// Dummy value to associate with an Object in the backing Map -private static final Object PRESENT = new Object(); -``` - -我们发现,在添加元素时,其实添加的是一个键为我们插入的元素,而值就是`PRESENT`常量: - -```java -/** - * Adds the specified element to this set if it is not already present. - * More formally, adds the specified element e to this set if - * this set contains no element e2 such that - * (e==null ? e2==null : e.equals(e2)). - * If this set already contains the element, the call leaves the set - * unchanged and returns false. - * - * @param e element to be added to this set - * @return true if this set did not already contain the specified - * element - */ -public boolean add(E e) { - return map.put(e, PRESENT)==null; -} -``` - -观察其他的方法,也几乎都是在用HashMap做事,所以说,HashSet利用了HashMap内部的数据结构,轻松地就实现了Set定义的全部功能! - -再来看TreeSet,实际上用的就是我们的TreeMap: - -```java -/** - * The backing map. - */ -private transient NavigableMap m; -``` - -同理,这里就不多做阐述了。 - -#### JDK1.8新增方法使用 - -最后,我们再来看看JDK1.8中集合类新增的一些操作(之前没有提及的)首先来看看`compute`方法: - -```java -public static void main(String[] args) { - Map map = new HashMap<>(); - map.put(1, "A"); - map.put(2, "B"); - map.compute(1, (k, v) -> { //compute会将指定Key的值进行重新计算,若Key不存在,v会返回null - return v+"M"; //这里返回原来的value+M - }); - map.computeIfPresent(1, (k, v) -> { //当Key存在时存在则计算并赋予新的值 - return v+"M"; //这里返回原来的value+M - }); - System.out.println(map); -} -``` - -也可以使用`computeIfAbsent`,当不存在Key时,计算并将键值对放入Map - -```java -public static void main(String[] args) { - Map map = new HashMap<>(); - map.put(1, "A"); - map.put(2, "B"); - map.computeIfAbsent(0, (k) -> { //若不存在则计算并插入新的值 - return "M"; //这里返回M - }); - System.out.println(map); -} -``` - -merge方法用于处理数据: - -```java -public static void main(String[] args) { - List students = Arrays.asList( - new Student("yoni", "English", 80), - new Student("yoni", "Chiness", 98), - new Student("yoni", "Math", 95), - new Student("taohai.wang", "English", 50), - new Student("taohai.wang", "Chiness", 72), - new Student("taohai.wang", "Math", 41), - new Student("Seely", "English", 88), - new Student("Seely", "Chiness", 89), - new Student("Seely", "Math", 92) - ); - Map scoreMap = new HashMap<>(); - students.forEach(student -> scoreMap.merge(student.getName(), student.getScore(), Integer::sum)); - scoreMap.forEach((k, v) -> System.out.println("key:" + k + "总分" + "value:" + v)); -} - -static class Student { - private final String name; - private final String type; - private final int score; - - public Student(String name, String type, int score) { - this.name = name; - this.type = type; - this.score = score; - } - - public String getName() { - return name; - } - - public int getScore() { - return score; - } - - public String getType() { - return type; - } -} -``` - -*** - -### 集合的嵌套 - -既然集合类型中的元素类型是泛型,那么能否嵌套存储呢? - -```java -public static void main(String[] args) { - Map> map = new HashMap<>(); //每一个映射都是 字符串<->列表 - map.put("卡布奇诺今犹在", new LinkedList<>()); - map.put("不见当年倒茶人", new LinkedList<>()); - System.out.println(map.keySet()); - System.out.println(map.values()); -} -``` - -通过Key获取到对应的值后,就是一个列表: - -```java -map.get("卡布奇诺今犹在").add(10); -System.out.println(map.get("卡布奇诺今犹在").get(0)); -``` - -让套娃继续下去: - -```java -public static void main(String[] args) { - Map>> map = new HashMap<>(); -} -``` - -你也可以使用List来套娃别的: - -```java -public static void main(String[] args) { - List>> list = new LinkedList<>(); -} -``` - -### 流Stream和Optional的使用 - -Java 8 API添加了一个新的抽象称为流Stream,可以让你以一种声明的方式处理数据。Stream 使用一种类似用 SQL 语句从数据库查询数据的直观方式来提供一种对 Java 集合运算和表达的高阶抽象。Stream API可以极大提高Java程序员的生产力,让程序员写出高效率、干净、简洁的代码。这种风格将要处理的元素集合看作一种流, 流在管道中传输, 并且可以在管道的节点上进行处理, 比如筛选, 排序,聚合等。元素流在管道中经过中间操作(intermediate operation)的处理,最后由最终操作(terminal operation)得到前面处理的结果。 - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fseo-1255598498.file.myqcloud.com%2Ffull%2F723b4e9e03e9f1cbd9078f60b265e3ddc8a582aa.jpg&refer=http%3A%2F%2Fseo-1255598498.file.myqcloud.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1634782830&t=8d9a60d60087bec5ce0d09b763eb3805) - -它看起来就像一个工厂的流水线一样!我们就可以把一个Stream当做流水线处理: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(); - list.add("A"); - list.add("B"); - list.add("C"); - - //移除为B的元素 - Iterator iterator = list.iterator(); - while (iterator.hasNext()){ - if(iterator.next().equals("B")) iterator.remove(); - } - - //Stream操作 - list = list //链式调用 - .stream() //获取流 - .filter(e -> !e.equals("B")) //只允许所有不是B的元素通过流水线 - .collect(Collectors.toList()); //将流水线中的元素重新收集起来,变回List - System.out.println(list); -} -``` - -可能从上述例子中还不能感受到流处理带来的便捷,我们通过下面这个例子来感受一下: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(); - list.add(1); - list.add(2); - list.add(3); - list.add(3); - - list = list - .stream() - .distinct() //去重(使用equals判断) - .sorted((a, b) -> b - a) //进行倒序排列 - .map(e -> e+1) //每个元素都要执行+1操作 - .limit(2) //只放行前两个元素 - .collect(Collectors.toList()); - - System.out.println(list); -} -``` - -当遇到大量的复杂操作时,我们就可以使用Stream来快速编写代码,这样不仅代码量大幅度减少,而且逻辑也更加清晰明了(如果你学习过SQL的话,你会发现它更像一个Sql语句) - -**注意**:不能认为每一步是直接依次执行的! - -```java -List list = new ArrayList<>(); -list.add(1); -list.add(2); -list.add(3); -list.add(3); - -list = list - .stream() - .distinct() //断点 - .sorted((a, b) -> b - a) - .map(e -> { - System.out.println(">>> "+e); //断点 - return e+1; - }) - .limit(2) //断点 - .collect(Collectors.toList()); -//实际上,stream会先记录每一步操作,而不是直接开始执行内容,当整个链式调用完成后,才会依次进行! -``` - -接下来,我们用一堆随机数来进行更多流操作的演示: - -```java -public static void main(String[] args) { - Random random = new Random(); //Random是一个随机数工具类 - random - .ints(-100, 100) //生成-100~100之间的,随机int型数字(本质上是一个IntStream) - .limit(10) //只获取前10个数字(这是一个无限制的流,如果不加以限制,将会无限进行下去!) - .filter(i -> i < 0) //只保留小于0的数字 - .sorted() //默认从小到大排序 - .forEach(System.out::println); //依次打印 -} -``` - -我们可以生成一个统计实例来帮助我们快速进行统计: - -```java -public static void main(String[] args) { - Random random = new Random(); //Random是一个随机数工具类 - IntSummaryStatistics statistics = random - .ints(0, 100) - .limit(100) - .summaryStatistics(); //获取语法统计实例 - System.out.println(statistics.getMax()); //快速获取最大值 - System.out.println(statistics.getCount()); //获取数量 - System.out.println(statistics.getAverage()); //获取平均值 -} -``` - -普通的List只需要一个方法就可以直接转换到方便好用的IntStream了: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(); - list.add(1); - list.add(1); - list.add(2); - list.add(3); - list.add(4); - list.stream() - .mapToInt(i -> i) //将每一个元素映射为Integer类型(这里因为本来就是Integer) - .summaryStatistics(); -} -``` - -我们还可以通过`flat`来对整个流进行进一步细分: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(); - list.add("A,B"); - list.add("C,D"); - list.add("E,F"); //我们想让每一个元素通过,进行分割,变成独立的6个元素 - list = list - .stream() //生成流 - .flatMap(e -> Arrays.stream(e.split(","))) //分割字符串并生成新的流 - .collect(Collectors.toList()); //汇成新的List - System.out.println(list); //得到结果 -} -``` - -我们也可以只通过Stream来完成所有数字的和,使用`reduce`方法: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(); - list.add(1); - list.add(2); - list.add(3); - int sum = list - .stream() - .reduce((a, b) -> a + b) //计算规则为:a是上一次计算的值,b是当前要计算的参数,这里是求和 - .get(); //我们发现得到的是一个Optional类实例,不是我们返回的类型,通过get方法返回得到的值 - System.out.println(sum); -} -``` - -通过上面的例子,我们发现,Stream不喜欢直接给我们返回一个结果,而是通过Optinal的方式,那么什么是Optional呢? - -Optional类是Java8为了解决null值判断问题,使用Optional类可以避免显式的null值判断(null的防御性检查),避免null导致的NPE(NullPointerException)。总而言之,就是对控制的一个判断,为了避免空指针异常。 - -```java -public static void main(String[] args) { - String str = null; - if(str != null){ //当str不为空时添加元素到List中 - list.add(str); - } -} -``` - -有了Optional之后,我们就可以这样写: - -```java -public static void main(String[] args) { - String str = null; - Optional optional = Optional.ofNullable(str); //转换为Optional - optional.ifPresent(System.out::println); //当存在时再执行方法 -} -``` - -就类似于Kotlin中的: - -```js -var str : String? = null -str?.upperCase() -``` - -我们可以选择直接get或是当值为null时,获取备选值: - -```java -public static void main(String[] args) { - String str = null; - Optional optional = Optional.ofNullable(str); //转换为Optional(可空) - System.out.println(optional.orElse("lbwnb")); - // System.out.println(optional.get()); 这样会直接报错 -} -``` - -同样的,Optional也支持过滤操作和映射操作,不过是对于单对象而言: - -```java -public static void main(String[] args) { - String str = "A"; - Optional optional = Optional.ofNullable(str); //转换为Optional(可空) - System.out.println(optional.filter(s -> s.equals("B")).get()); //被过滤了,此时元素为null,获取时报错 -} -``` - -```java -public static void main(String[] args) { - List list = new ArrayList<>(); - String str = "A"; - Optional optional = Optional.ofNullable(str); //转换为Optional(可空) - System.out.println(optional.map(s -> s + "A").get()); //在尾部追加一个A -} -``` - -其他操作自学了解。 - -### Arrays和Collections的使用 - -Arrays是一个用于操作数组的工具类,它给我们提供了大量的工具方法: - -```java -/** - * This class contains various methods for manipulating arrays (such as - * sorting and searching). This class also contains a static factory - * that allows arrays to be viewed as lists. <- 注意,这句话很关键 - * - * @author Josh Bloch - * @author Neal Gafter - * @author John Rose - * @since 1.2 - */ -public class Arrays { -``` - -由于操作数组并不像集合那样方便,因此JDK提供了Arrays类来增强对数组操作,比如: - -```java -public static void main(String[] args) { - int[] array = {1, 5, 2, 4, 7, 3, 6}; - Arrays.sort(array); //直接进行排序(底层原理:进行判断,元素少使用插入排序,大量元素使用双轴快速/归并排序) - System.out.println(array); //由于int[]是一个对象类型,而数组默认是没有重写toString()方法,因此无法打印到想要的结果 - System.out.println(Arrays.toString(array)); //我们可以使用Arrays.toString()来像集合一样直接打印每一个元素出来 -} -``` - -```java -public static void main(String[] args) { - int[] array = {1, 5, 2, 4, 7, 3, 6}; - Arrays.sort(array); - System.out.println("排序后的结果:"+Arrays.toString(array)); - System.out.println("目标元素3位置为:"+Arrays.binarySearch(array, 3)); //二分搜素,必须是已经排序好的数组! -} -``` - -```java -public static void main(String[] args) { - int[] array = {1, 5, 2, 4, 7, 3, 6}; - Arrays - .stream(array) //将数组转换为流进行操作 - .sorted() - .forEach(System.out::println); -} -``` - -```java -public static void main(String[] args) { - int[] array = {1, 5, 2, 4, 7, 3, 6}; - int[] array2 = Arrays.copyOf(array, array.length); //复制一个一模一样的数组 - System.out.println(Arrays.toString(array2)); - - System.out.println(Arrays.equals(array, array2)); //比较两个数组是否值相同 - - Arrays.fill(array, 0); //将数组的所有值全部填充为指定值 - System.out.println(Arrays.toString(array)); - - Arrays.setAll(array2, i -> array2[i] + 2); //依次计算每一个元素(注意i是下标位置) - System.out.println(Arrays.toString(array2)); //这里计算让每个元素值+2 -} -``` - -思考:当二维数组使用`Arrays.equals()`进行比较以及`Arrays.toString()`进行打印时,还会得到我们想要的结果吗? - -```java -public static void main(String[] args) { - Integer[][] array = {{1, 5}, {2, 4}, {7, 3}, {6}}; - Integer[][] array2 = {{1, 5}, {2, 4}, {7, 3}, {6}}; - System.out.println(Arrays.toString(array)); //这样还会得到我们想要的结果吗? - System.out.println(Arrays.equals(array2, array)); //这样还会得到true吗? - - System.out.println(Arrays.deepToString(array)); //使用deepToString就能到打印多维数组 - System.out.println(Arrays.deepEquals(array2, array)); //使用deepEquals就能比较多维数组 -} -``` - -那么,一开始提到的当做List进行操作呢?我们可以使用`Arrays.asList()`来将数组转换为一个 **固定长度的List** - -```java -public static void main(String[] args) { - Integer[] array = {1, 5, 2, 4, 7, 3, 6}; - List list = Arrays.asList(array); //不支持基本类型数组,必须是对象类型数组 - Arrays.asList("A", "B", "C"); //也可以逐个添加,因为是可变参数 - - list.add(1); //此List实现是长度固定的,是Arrays内部单独实现的一个类型,因此不支持添加操作 - list.remove(0); //同理,也不支持移除 - - list.set(0, 8); //直接设置指定下标的值就可以 - list.sort(Comparator.reverseOrder()); //也可以执行排序操作 - System.out.println(list); //也可以像List那样直接打印 -} -``` - -文字游戏:`allows arrays to be viewed as lists`,实际上只是当做List使用,本质还是数组,因此数组的属性依然存在!因此如果要将数组快速转换为实际的List,可以像这样: - -```java -public static void main(String[] args) { - Integer[] array = {1, 5, 2, 4, 7, 3, 6}; - List list = new ArrayList<>(Arrays.asList(array)); -} -``` - -通过自行创建一个真正的ArrayList并在构造时将Arrays的List值传递。 - -既然数组操作都这么方便了,集合操作能不能也安排点高级的玩法呢?那必须的,JDK为我们准备的Collocations类就是专用于集合的工具类: - -```java -public static void main(String[] args) { - List list = new ArrayList<>(); - Collections.max(list); - Collections.min(list); -} -``` - -当然,Collections提供的内容相比Arrays会更多,希望大家下去自行了解,这里就不多做介绍了。 - -*** - -## 集合类编程实战 - -### 反转链表 - -1 <- 3 <- 5 <- 7 <- 9 转换为 1 <- 3 <- 5 <- 7 <- 9 - -现在有一个单链表,尝试将其所有节点倒序排列 - -```java -public class Main { - public static void main(String[] args) { - Node head = new Node(1); - head.next = new Node(3); - head.next.next = new Node(5); - head.next.next.next = new Node(7); - head.next.next.next.next = new Node(9); - - head = reverse(head); - - while (head != null){ - System.out.println(head.value+" "); - head = head.next; - } - } - - public static class Node { - public int value; - public Node next; - - public Node(int data) { - this.value = data; - } - } - - public static Node reverse(Node head) { - //在这里实现 - } -} -``` - -### 重建二叉树 - -现在知道二叉树的前序: GDAFEMHZ,以及中序: ADEFGHMZ,请根据已知信息还原这颗二叉树。 - -![这里写图片描述](https://imgconvert.csdnimg.cn/aHR0cDovL2ltZy5ibG9nLmNzZG4ubmV0LzIwMTcxMTEwMTExNTE3NDcx?x-oss-process=image/format,png) - -### 实现计算器 - -实现一个计算器,要求输入一个计算公式(含加减乘除运算符,没有负数但是有小数),得到结果,比如输入:1+4*3/1.321,得到结果为:2.2 - -### 字符串匹配(KMP算法) - -现在给定一个主字符串和一个子字符串,请判断主字符串是否包含子字符串,例如主字符串:ABCABCDHI,子字符串:ABCD,因此主字符串包含此子字符串;主字符串:ABCABCUISA,子字符串:ABCD,则不包含。 - diff --git a/青空笔记/JavaSE 笔记(含新特性介绍)/Java关键字总结.md b/青空笔记/JavaSE 笔记(含新特性介绍)/Java关键字总结.md deleted file mode 100644 index ede5633..0000000 --- a/青空笔记/JavaSE 笔记(含新特性介绍)/Java关键字总结.md +++ /dev/null @@ -1,410 +0,0 @@ -![image-20221024140057637](/Users/nagocoler/Library/Application Support/typora-user-images/image-20221024140057637.png) - -# Java关键字总结 - -我们在JavaSE阶段中学习了几乎所有的Java关键字(剩余关键字我们在SE路线的其他篇章中介绍过)这里,我们来对这些关键字进行一次详细的总结。 - -在Java中(截止Java17版本)目前一共有53个关键字、2个保留字、3个特殊常量,总共58个关键字,这里我们分为下面的几大类来进行总结。 - -## 数据类型(10个) - -基本数据类型包括四个整数类型:`int`、`long`、`short`、`byte`,两个浮点类型:`float`、`double`,一个字符类型:`char`,还有一个比较特殊的`boolean`类型,它们的所占空间大小为: - -- byte 字节型 (8个bit,也就是1个字节)范围:-128~+127 -- short 短整形(16个bit,也就是2个字节)范围:-32768~+32767 -- int 整形(32个bit,也就是4个字节)最常用的类型:-2147483648 ~ +2147483647 -- long 长整形(64个bit,也就是8个字节)范围:-9223372036854775808 ~ +9223372036854775807 -- float 单精度浮点型 (32bit,4字节) -- double 双精度浮点型(64bit,8字节) -- char 字符型(16个bit,也就是2字节,它不带符号)范围是0 ~ 65535 -- boolean 布尔型(大小依不同JVM实现决定) - -当然,某些时候我们的方法可能没有返回值,此时我们需要使用`void`类型来表示无返回值: - -```java -public void test(){ - -} -``` - -在 Java 10 版本之后,新增一个`var`关键字来自动进行类型推断: - -```java -public static void main(String[] args) { - // String a = "Hello World!"; 之前我们定义变量必须指定类型 - var a = "Hello World!"; //现在我们使用var关键字来自动进行类型推断,因为完全可以从后面的值来判断是什么类型 -} -``` - -以上10个关键字都是数据类型相关的关键字。 - -*** - -## 特殊常量(3个) - -在Java中,有着三个特殊常量值,首先就是`boolean`类型: - -* `true` - 真 -* `false` - 假 - -布尔类型只有两个值,要么是真要么是假,没有其他的情况,因此我们一般在进行流程判断时就会使用到布尔类型值作为判断依据。 - -当然,除了基本数据类型会存在特殊常量,引用类型同样存在: - -* `null` - 表示空,不引用任何对象 - -当一个引用类型变量的值为null时,访问对象任意属性和方法时都会出现空指针异常,因为此时并没有引用任何一个对象。 - -*** - -## 流程控制(10个) - -流程控制语句相关的关键字也是非常多的,首先是分支语句:`if`、`else`、`switch`、`case`,这四个关键字是我们在分支条件中会常常用到的: - -```java -if (condition) { - -} else { - -} -``` - -有些时候我们也需要循环语句来完成一些批量操作,我们可以使用`for`、`while`、`do`这三个关键字来实现循环结构: - -```java -do { - -} while (condition); -``` - -对于分支和循环,我们还会对其进行进一步地控制,比如加速循环或是跳出循环等,我们会用到: - -* `break` - 用于跳出循环、结束switch中的case块。 -* `continue` - 用于加速循环的进行。 - -我们也可以使用`return`关键字来结束整个方法: - -```java -public static void main(String[] args) { - return; -} -``` - -它的作用有: - -* 直接结束当前方法 -* 返回方法的运行结果(返回值) - -以上10个关键字都是用于流程控制的。 - -*** - -## 访问控制(6个) - -首先我们需要介绍一下`final`关键字,这个关键字的用途很多: - -* 将变量声明为常量,首次赋值后无法被修改(无论是基本类型还是引用类型) -* 将类表明为终态,此类将无法被继承。 -* 将方法表明为终态,此方法将无法被重写。 - -接着是三个权限修饰符:`public`、`private`、`protected`,不同的权限修饰符决定了外部对当前内容的访问权限: - -| | 当前类 | 同一个包下的类 | 不同包下的子类 | 不同包下的类 | -| :-------: | :----: | :------------: | :------------: | :----------: | -| public | ✅ | ✅ | ✅ | ✅ | -| protected | ✅ | ✅ | ✅ | ❌ | -| 默认 | ✅ | ✅ | ❌ | ❌ | -| private | ✅ | ❌ | ❌ | ❌ | - -我们可以使用`package`关键字来表明当前类所处的包: - -```java -package com.test; - -public class Main { - -} -``` - -对于非同包下的类或是非默认导入包下的类,我们需要使用`import`关键字进行导入之后才可以使用: - -```java -import java.util.Arrays; - -public class Main { - public static void main(String[] args) { - Arrays.asList(); - } -} -``` - -以上6个关键字是与访问控制相关的关键字。 - -*** - -## 类与对象(16个) - -首先,如果我们要声明一个新的类型,肯定是要用到`class`关键字的,它主要有两个功能: - -* 声明一个新的类型。 -* 获取类的Class对象(基本类型和引用类型都有对应的Class对象) - -```java -public static void main(String[] args) { - Class clazz = String.class; //任意类型都可以直接.class来获取对应的Class对象 - System.out.println(clazz.getName()); -} -``` - -我们可以使用`new`关键字直接调用类的构造方法进行对象创建: - -```java -public static void main(String[] args) { - Object o = new Object(); -} -``` - -我们可以使用`this`关键字来表示当前对象本身: - -* 表示当前对象本身。 -* 在类内部使用当前类对象的属性。 -* 在类内部调用当前类对象的方法。 -* 调用当前类的构造器。 -* 表示外部类的对象属性和方法。 - -同样的,如果我们需要使用父类定义的内容,可以使用`super`关键字: - -* 在类内部使用父类的属性。 -* 在类内部调用父类的方法。 -* 调用父类的构造器。 -* 表示外部类的父类属性和方法。 - -当我们需要将一个类作为抽象类时,可以为其添加`abstract`关键字: - -* 将类型声明为抽象类,抽象类可以具有抽象方法。 -* 将方法声明为抽象方法,不需要实现方法体,而是交给子类实现。 - -```java -abstract class Test { - abstract void hello(); -} -``` - -我们可以使用`extends`关键字来指明继承关系,但是只能继承一个类: - -```java -public class Main extends Number{ - -} -``` - -对于接口的定义,我们可以使用`interface`关键字: - -```java -public interface Cloneable { - -} -``` - -当我们实现接口时,就需要使用`implements`关键字: - -```java -public class Main implements Cloneable { - -} -``` - -特别的,我们可以为接口中的抽象方法添加默认实现,需要添加`default`关键字: - -```java -default Stream stream() { - return StreamSupport.stream(spliterator(), false); -} -``` - -这个关键字有两个作用: - -* 为接口中的抽象方法添加默认实现。 -* 为switch语句指定其他分支。 - -当我们要判断某个对象是否为某个类型或是某个类型/接口的实现时,可以使用`instanceof`关键字: - -```java -public static void main(String[] args) { - Object obj = "你干嘛哎哟"; - System.out.println(obj instanceof String); -} -``` - -对于类中的属性,我们可以将其声明为`static`静态的,表示这些属性是属于类的,而不是对象: - -```java -public static List asList(T... a) { - return new ArrayList<>(a); -} -``` - -```java -public static void main(String[] args) { - Arrays.asList("AAA"); //类属性可以直接通过类名.进行使用 -} -``` - -我们还可以使用`enum`关键字来定义枚举类型: - -```java -public enum Status{ - HAPPY, SAD -} -``` - -在Java14中,新增了记录类型,我们可以直接使用`record`关键字创建一个记录类型: - -```java -public record Account(String username, String password) { //直接把字段写在括号中 - -} -``` - -Lombok的噩梦来了。 - -在Java17中,正式新增了密封类型,密封类的作用就是**限制类的继承**,包含三个新的关键字`sealed`、`non-sealed`、`permits`。 - -```java -public sealed class A permits B{ //在class关键字前添加sealed关键字,表示此类为密封类型,permits后面跟上允许继承的类型,多个子类使用逗号隔开 - -} -``` - -只允许我们自己写的类继承A,但是不允许别人写的类继承A,就可以像上面这样实现了。 - -以上关键字都是我们在面向对象中认识的。 - -*** - -## 异常处理(6个) - -当我们要抛出异常时,可以直接使用`throw`关键字: - -```java -public static void main(String[] args) { - throw new RuntimeException(); -} -``` - -我们也可以将需要外部处理的异常通过`throws`关键字列出: - -```java -public void test() throws IOException, ReflectiveOperationException { - -} -``` - -对于异常的处理,我们可以使用`try-catch`语句块来完成: - -```java -public static void main(String[] args) { - try (FileInputStream stream = new FileInputStream("hello.txt")){ - - } catch (IOException e){ - e.printStackTrace(); - } -} -``` - -对于那些无论是否发生异常都必须要在最后执行的内容,我们可以使用`finally`语句块: - -```java -public static void main(String[] args) { - try (FileInputStream stream = new FileInputStream("hello.txt")){ - - } catch (IOException e){ - e.printStackTrace(); - } finally { - System.out.println("我必须执行!"); - } -} -``` - -我们还介绍了`assert`断言语句,当后面的条件不满足时,会直接抛出错误: - -```java -public static void main(String[] args) { - assert 1 == 1; -} -``` - -以上都是异常处理中会使用到的一些关键字。 - -*** - -## 多线程(2个) - -在多线程环境下,同步问题显得尤为重要,我们可以使用`synchronized`关键字,来添加同步代码块或是同步方法: - -```java -public static void main(String[] args) { - synchronized (Main.class) { - - } -} -``` - -只有获取到对应的锁之后,才能进入到同步代码块中,这样同一时间只能有一个线程执行这段代码。 - -我们在JUC篇中讲解了一个新的关键字`volatile`,这个关键字是用于保证可见性的,我们之前说了,如果多线程访问同一个变量,那么这个变量会被线程拷贝到自己的工作内存中进行操作,而不是直接对主内存中的变量本体进行操作,下面这个操作看起来是一个有限循环,但是是无限的: - -```java -public class Main { - private static int a = 0; - public static void main(String[] args) throws InterruptedException { - new Thread(() -> { - while (a == 0); - System.out.println("线程结束!"); - }).start(); - - Thread.sleep(1000); - System.out.println("正在修改a的值..."); - a = 1; //很明显,按照我们的逻辑来说,a的值被修改那么另一个线程将不再循环 - } -} -``` - -虽然我们主线程中修改了a的值,但是另一个线程并不知道a的值发生了改变,所以循环中依然是使用旧值在进行判断,因此,普通变量是不具有可见性的。 - -此时我们可以使用`volatile`关键字来解决,此关键字的第一个作用,就是保证变量的可见性。当写一个`volatile`变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去,并且这个写会操作会导致其他线程中的`volatile`变量缓存无效,这样,另一个线程修改了这个变时,当前线程会立即得知,并将工作内存中的变量更新为最新的版本。 - -它的功能如下: - -* 保证变量的可见性。 -* 防止指令重排序。 -* 不保证原子性。 - -*** - -## 其他(5个) - -这里需要介绍一个我们从来没有介绍过的关键字`strictfp`,它的使用频率极低,这个关键字作用很简单: - -* strictfp可以保证浮点数运算的精确性,而且在不同的硬件平台会有一致的运行结果。 - -一旦使用了strictfp来声明一个类、接口或者方法时,那么所声明的范围内Java的编译器以及运行环境会完全依照浮点规范IEEE-754来执行。因此如果想让浮点运算更加精确,而且不会因为不同的硬件平台所执行的结果不一致的话,那就可以使用关键字strictfp来解决,而在Java17之后直接调整为始终严格,所有这里我们只做了解就行。 - -我们在讲解Object类时,会发现有一些方法添加了`native`关键字: - -```java -@IntrinsicCandidate -public native int hashCode(); -``` - -可以看到这种方法时没有方法体的,也就是说没有实现,而真正的实现是由C/C++编写的,我们在JVM篇中有详细介绍,各位小伙伴如果感兴趣的话可以去看看。 - -此外,我们在学习IO时认识了对象流,我们可以让类实现序列化接口,并将类序列化为二进制数据存放到文件中,当时我们说,如果不希望某个属性被序列化,就可以添加`transient`关键字: - -```java -public class Main { - transient String name; -} -``` - -Java中还有两个保留字,目前没有任何作用,但是就是有:`goto`、`const` \ No newline at end of file diff --git a/青空笔记/JavaSE 笔记(含新特性介绍)/Java新特性介绍.md b/青空笔记/JavaSE 笔记(含新特性介绍)/Java新特性介绍.md deleted file mode 100644 index 17ff0cb..0000000 --- a/青空笔记/JavaSE 笔记(含新特性介绍)/Java新特性介绍.md +++ /dev/null @@ -1,1395 +0,0 @@ -![image-20220528201059618](https://tva1.sinaimg.cn/large/e6c9d24egy1h2odnbqcwuj21g00i2n3c.jpg) - -# Java新特性介绍 - -**注意:**推荐完成此路线所有前置内容后,再来学习本篇。 - -经过前面的学习,我们基本已经了解了Java 8及之前的所有语法,不过,Java 8是Oracle 公司于 2014 年 3 月 18 日发布的,距离今天已经过了近十年的时间了,Java并没有就此止步,而是继续不断发展壮大,几乎每隔6个月,就会冒出一个新版本,最新的版本已经快要迭代到Java 20了,与Java 8相差了足足十来个版本,但是由于Java 8的稳定和生态完善(目前仍是LTS长期维护版本),依然有很多公司在坚持使用Java 8,不过随着SpringBoot 3.0的到来,现在强制要求使用Java 17版本(同样也是LTS长期维护版本),下一个Java版本的时代,或许已经临近了。 - -![image-20220528202952628](https://tva1.sinaimg.cn/large/e6c9d24egy1h2oe6x6fipj215i0cqtan.jpg) - -随着这些主流框架全面拥抱Java 17,为了不被时代所淘汰,我们的学习之路,也要继续前行了。就像很多年前Java 6还是主流的时代,终究还是被Java 8所取代一样。 - -在本篇视频中,我们将介绍Java 9 - Java 17这些版本的所有新增特性,这里推荐各位小伙伴提前准备好JDK 17环境(Oracle JDK 17已全面支持arm芯片的Mac电脑,请放心食用) - -![image-20220528220146662](https://tva1.sinaimg.cn/large/e6c9d24egy1h2ogujjr85j22740p0wjz.jpg) - -全篇视频挑重点说,不墨迹,开始吧。 - -## Java 8 关键特性回顾 - -在开始之前,我们先来回顾一下Java 8中学习的Lambda表达式和Optional类,有关Stream API请各位小伙伴回顾一下Java SE篇视频教程,这里不再进行介绍。 - -### Lambda表达式 - -在Java 8之前,我们在某些情况下可能需要用到匿名内部类,比如: - -```java -public static void main(String[] args) { - //现在我们想新建一个线程来搞事情 - Thread thread = new Thread(new Runnable() { //创建一个实现Runnable的匿名内部类 - @Override - public void run() { //具体的实现逻辑 - System.out.println("Hello World!"); - } - }); - thread.start(); -} -``` - -在创建Thread时,我们需要传入一个Runnable接口的实现类,来指定具体的在新的线程中要执行的任务,相关的逻辑需要我们在`run()`方法中实现,这时为了方便,我们就直接使用匿名内部类的方式传入一个实现,但是这样的写法实在是太过臃肿了。 - -在Java 8之后,我们可以对类似于这种匿名内部类的写法,进行缩减,实际上我们进行观察会发现,真正有用的那一部分代码,实际上就是我们对`run()`方法的具体实现,而其他的部分实际上在任何地方编写都是一模一样的,那么我们能否针对于这种情况进行优化呢?我们现在只需要一个简短的lambda表达式即可: - -```java -public static void main(String[] args) { - //现在我们想新建一个线程来做事情 - Thread thread = new Thread(() -> { - System.out.println("Hello World!"); //只需留下我们需要具体实现的方法体 - }); - thread.start(); -} -``` - -我们可以发现,原本需要完整编写包括类、方法在内的所有内容,全部不再需要,而是直接使用类似于`() ‐> { 代码语句 }`的形式进行替换即可。是不是感觉瞬间代码清爽了N倍? - -当然这只是一种写法而已,如果各位不好理解,可以将其视为之前匿名内部类写法的一种缩短。 - -> 但是注意,它的底层其实并不只是简简单单的语法糖替换,而是通过`invokedynamic`指令实现的,不难发现,匿名内部类会在编译时创建一个单独的class文件,但是lambda却不会,间接说明编译之后lambda并不是以匿名内部类的形式存在的: -> -> ```java -> //现在我们想新建一个线程来做事情 -> Thread thread = new Thread(() -> { -> throw new UnsupportedOperationException(); //这里我们拋个异常看看 -> }); -> thread.start(); -> ``` -> -> ![image-20220529214948350](https://tva1.sinaimg.cn/large/e6c9d24egy1h2pm4fpasnj21e202qdgc.jpg) -> -> 可以看到,实际上是Main类中的`lambda$main$0()`方法抛出的异常,但是我们的Main类中压根没有这个方法,很明显是自动生成的。所以,与其说Lambda是匿名内部类的语法糖,不如说是我们为所需要的接口提供了一个方法作为它的实现。比如Runnable接口需要一个方法体对它的`run()`方法进行实现,而这里我们就通过lambda的形式给了它一个方法体,这样就万事具备了,而之后创建实现类就只需要交给JVM去处理就好了。 - -我们来看一下Lambda表达式的具体规范: - -* 标准格式为:`([参数类型 参数名称,]...) ‐> { 代码语句,包括返回值 }` -* 和匿名内部类不同,Lambda仅支持接口,不支持抽象类 -* 接口内部必须有且仅有一个抽象方法(可以有多个方法,但是必须保证其他方法有默认实现,必须留一个抽象方法出来) - -比如我们之前使用的Runable类: - -```java -@FunctionalInterface //添加了此注解的接口,都支持lambda表达式,符合函数式接口定义 -public interface Runnable { - public abstract void run(); //有且仅有一个抽象方法,此方法返回值为void,且没有参数 -} -``` - -因此,Runable的的匿名内部类实现,就可以简写为: - -```java -Runnable runnable = () -> { }; -``` - -我们也可以写一个玩玩: - -```java -@FunctionalInterface -public interface Test { //接口类型 - String test(Integer i); //只有这一个抽象方法,且接受一个int类型参数,返回一个String类型结果 -} -``` - -它的Lambda表达式的实现就可以写为: - -```java -Test test = (Integer i) -> { return i+""; }; //这里我们就简单将i转换为字符串形式 -``` - -不过还可以进行优化,首先方法参数类型是可以省略的: - -```java -Test test = (i) -> { return i+""; }; -``` - -由于只有一个参数,可以不用添加小括号(多个参数时需要): - -```java -Test test = i -> { return i+""; }; -``` - -由于仅有返回语句这一行,所以可以直接写最终返回的结果,并且无需花括号: - -```java -Test test = i -> i+""; -``` - -这样,相比我们之前直接去编写一个匿名内部类,是不是简介了很多很多。当然,除了我们手动编写接口中抽象方法的方法体之外,如果已经有实现好的方法,是可以直接拿过来用的,比如: - -```java -String test(Integer i); //接口中的定义 -``` - -```java -public static String impl(Integer i){ //现在有一个静态方法,刚好匹配接口中抽象方法的返回值和参数列表 - return "我是已经存在的实现"+i; -} -``` - -所以,我们可以直接将此方法,作为lambda表达式的方法体实现(其实这就是一种方法引用,引用了一个方法过来,这也是为什么前面说`是我们为所需要的接口提供了一个方法作为它的实现`,是不是越来越体会到这句话的精髓了): - -```java -public static void main(String[] args) { - Test test = Main::impl; //使用 类名::方法名称 的形式来直接引用一个已有的方法作为实现 -} - -public static String impl(Integer i){ - return "我是已经存在的实现"+i; -} -``` - -比如我们现在需要对一个数组进行排序: - -```java -public static void main(String[] args) { - Integer[] array = new Integer[]{4, 6, 1, 9, 2, 0, 3, 7, 8, 5}; //来个数组 - Arrays.sort(array, new Comparator() { //Arrays.sort()可以由我们自己指定排序规则,只需要实现Comparator方法即可 - @Override - public int compare(Integer o1, Integer o2) { - return o1 - o2; - } - }); - System.out.println(Arrays.toString(array)); //按从小到大的顺序排列 -} -``` - -但是我们发现,Integer类中有一个叫做`compare`的静态方法: - -```java -public static int compare(int x, int y) { - return (x < y) ? -1 : ((x == y) ? 0 : 1); -} -``` - -这个方法是一个静态方法,但是它却和`Comparator`需要实现的方法返回值和参数定义一模一样,所以,懂的都懂: - -```java -public static void main(String[] args) { - Integer[] array = new Integer[]{4, 6, 1, 9, 2, 0, 3, 7, 8, 5}; - Arrays.sort(array, Integer::compare); //直接指定一手,效果和上面是一模一样 - System.out.println(Arrays.toString(array)); -} -``` - -那么要是不是静态方法而是普通的成员方法呢?我们注意到Comparator要求我们实现的方法为: - -```java -public int compare(Integer o1, Integer o2) { - return o1 - o2; -} -``` - -其中o1和o2都是Integer类型的,我们发现Integer类中有一个`compareTo`方法: - -```java -public int compareTo(Integer anotherInteger) { - return compare(this.value, anotherInteger.value); -} -``` - -只不过这个方法并不是静态的,而是对象所有: - -```java -Integer[] array = new Integer[]{4, 6, 1, 9, 2, 0, 3, 7, 8, 5}; -Arrays.sort(array, new Comparator() { - @Override - public int compare(Integer o1, Integer o2) { - return o1.compareTo(o2); //这样进行比较也行,和上面效果依然是一样的 - } -}); -System.out.println(Arrays.toString(array)); -``` - -但是此时我们会发现,IDEA提示我们可以缩写,这是为什么呢?实际上,当我们使用非静态方法时,会使用抽象方参数列表的第一个作为目标对象,后续参数作为目标对象成员方法的参数,也就是说,此时,`o1`作为目标对象,`o2`作为参数,正好匹配了`compareTo`方法,所以,直接缩写: - -```java -public static void main(String[] args) { - Integer[] array = new Integer[]{4, 6, 1, 9, 2, 0, 3, 7, 8, 5}; - Arrays.sort(array, Integer::compareTo); //注意这里调用的不是静态方法 - System.out.println(Arrays.toString(array)); -} -``` - -成员方法也可以让对象本身不成为参与的那一方,仅仅引用方法: - -```java -public static void main(String[] args) { - Main mainObject = new Main(); - Integer[] array = new Integer[]{4, 6, 1, 9, 2, 0, 3, 7, 8, 5}; - Arrays.sort(array, mainObject::reserve); //使用Main类的成员方法,但是mainObject对象并未参与进来,只是借用了一下刚好匹配的方法 - System.out.println(Arrays.toString(array)); -} - -public int reserve(Integer a, Integer b){ //现在Main类中有一个刚好匹配的方法 - return b.compareTo(a); -} -``` - -当然,类的构造方法同样可以作为方法引用传递: - -```java -public interface Test { - String test(String str); //现在我们需要一个参数为String返回值为String的实现 -} -``` - -我们发现,String类中刚好有一个: - -```java -public String(String original) { //由于String类的构造方法返回的肯定是一个String类型的对象,且此构造方法需要一个String类型的对象,所以,正好匹配了接口中的 - this.value = original.value; - this.coder = original.coder; - this.hash = original.hash; -} -``` - -于是乎: - -```java -public static void main(String[] args) { - Test test = String::new; //没错,构造方法直接使用new关键字就行 -} -``` - -当然除了上面提到的这些情况可以使用方法引用之外,还有很多地方都可以,还请各位小伙伴自行探索了。Java 8也为我们提供了一些内置的函数式接口供我们使用:Consumer、Function、Supplier等,具体请回顾一下JavaSE篇视频教程。 - -### Optional类 - -Java 8中新引入了Optional特性,来让我们更优雅的处理空指针异常。我们先来看看下面这个例子: - -```java -public static void hello(String str){ //现在我们要实现一个方法,将传入的字符串转换为小写并打印 - System.out.println(str.toLowerCase()); //那太简单了吧,直接转换打印一气呵成 -} -``` - -但是这样实现的话,我们少考虑了一个问题,万一给进来的`str`是`null`呢?如果是`null`的话,在调用`toLowerCase`方法时岂不是直接空指针异常了?所以我们还得判空一下: - -```java -public static void hello(String str){ - if(str != null) { - System.out.println(str.toLowerCase()); - } -} -``` - -但是这样写着就不能一气呵成了,我现在又有强迫症,我就想一行解决,这时,Optional来了,我们可以将任何的变量包装进Optional类中使用: - -```java -public static void hello(String str){ - Optional - .ofNullable(str) //将str包装进Optional - .ifPresent(s -> { //ifPresent表示只有对象不为null才会执行里面的逻辑,实现一个Consumer(接受一个参数,返回值为void) - System.out.println(s); - }); -} -``` - -由于这里只有一句打印,所以我们来优化一下: - -```java -public static void hello(String str){ - Optional - .ofNullable(str) //将str包装进Optional - .ifPresent(System.out::println); - //println也是接受一个String参数,返回void,所以这里使用我们前面提到的方法引用的写法 -} -``` - -这样,我们就又可以一气呵成了,是不是感觉比之前的写法更优雅。 - -除了在不为空时执行的操作外,还可以直接从Optional中获取被包装的对象: - -```java -System.out.println(Optional.ofNullable(str).get()); -``` - -不过此时当被包装的对象为null时会直接抛出异常,当然,我们还可以指定如果get的对象为null的替代方案: - -```java -System.out.println(Optional.ofNullable(str).orElse("VVV")); //orElse表示如果为空就返回里面的内容 -``` - -其他操作还请回顾JavaSE篇视频教程。 - -## Java 9 新特性 - -这一部分,我们将介绍Java 9为我们带来的新特性,Java 9的主要特性有,全新的模块机制、接口的private方法等。 - -### 模块机制 - -在我们之前的开发中,不知道各位有没有发现一个问题,就是当我们导入一个`jar`包作为依赖时(包括JDK官方库),实际上很多功能我们并不会用到,但是由于它们是属于同一个依赖捆绑在一起,这样就会导致我们可能只用到一部分内容,但是需要引用一个完整的类库,实际上我们可以把用不到的类库排除掉,大大降低依赖库的规模。 - -于是,Java 9引入了模块机制来对这种情况进行优化,在之前的我们的项目是这样的: - -![image-20220528210803658](https://tva1.sinaimg.cn/large/e6c9d24egy1h2ofan3qvxj21b409qjs3.jpg) - -而在引入模块机制之后: - -![image-20220528210958964](https://tva1.sinaimg.cn/large/e6c9d24egy1h2ofcn7rt7j219009y3za.jpg) - -可以看到,模块可以由一个或者多个在一起的 Java 包组成,通过将这些包分出不同的模块,我们就可以按照模块的方式进行管理了。这里我们创建一个新的项目,并在`src`目录下,新建`module-info.java`文件表示此项目采用模块管理机制: - -```java -module NewHelloWorld { //模块名称随便起一个就可以,但是注意必须是唯一的,以及模块内的包名也得是唯一的,即使模块不同 - -} -``` - -接着我们来创建一个主类: - -![image-20220528213210752](https://tva1.sinaimg.cn/large/e6c9d24egy1h2ofzqina8j21lo0bymyz.jpg) - -程序可以正常运行,貌似和之前没啥区别,不过我们发现,JDK为我们提供的某些框架不见了: - -![image-20220528213428296](https://tva1.sinaimg.cn/large/e6c9d24egy1h2og24c18zj216o07gwf7.jpg) - -Java为我们提供的`logging`相关日志库呢?我们发现现在居然不见了?实际上它就是被作为一个模块单独存在,这里我们需进行模块导入: - -```java -module NewHelloWorld { //模块名称随便起一个就可以 - requires java.logging; //除了JDK的一些常用包之外,只有我们明确需要的模块才会导入依赖库 - //当然如果要导入JavaSE的所有依赖,想之前一样的话,直接 requires java.se; 即可 -} -``` - -这里我们导入java.logging相关模块后,就可以正常使用Logger了: - -![image-20220528214247006](https://tva1.sinaimg.cn/large/e6c9d24egy1h2ogarnv3hj21oi0bewgd.jpg) - -![image-20220528214308194](https://tva1.sinaimg.cn/large/e6c9d24egy1h2ogb52z1fj214203ejrl.jpg) - -是不是瞬间感觉编写代码时清爽了许多,全新的模块化机制提供了另一个级别的Java代码可见性、可访问性的控制,不过,你以为仅仅是做了包的分离吗?我们可以来尝试通过反射获取JDK提供的类中的字段: - -```java -//Java17版本的String类 -public final class String - implements java.io.Serializable, Comparable, CharSequence, - Constable, ConstantDesc { - @Stable - private final byte[] value; //自JDK9后,为了提高性能,String底层数据存放的是byte[]而不是char[] -``` - -```java -public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException { - Class stringClass = String.class; - Field field = stringClass.getDeclaredField("value"); //这里我们通过反射来获取String类中的value字段 - field.setAccessible(true); //由于是private访问权限,所以我们修改一下 - System.out.println(field.get("ABCD")); -} -``` - -但是我们发现,在程序运行之后,修改操作被阻止了: - -![image-20220528221817482](https://tva1.sinaimg.cn/large/e6c9d24egy1h2ohbps200j22l008qgpe.jpg) - -反射 API 的 Java 9 封装和安全性得到了改进,如果模块没有明确授权给其他模块使用反射的权限,那么其他模块是不允许使用反射进行修改的,看来Unsafe类是玩不成了。 - -我们现在就来细嗦一下这个模块机制,首先模块具有四种类型: - -* **系统模块:**来自JDK和JRE的模块(官方提供的模块,比如我们上面用的),我们也可以直接使用`java --list-modules`命令来列出所有的模块,不同的模块会导出不同的包供我们使用。 -* **应用程序模块:**我们自己写的Java模块项目。 -* **自动模块:**可能有些库并不是Java 9以上的模块项目,这种时候就需要做兼容了,默认情况下是直接导出所有的包,可以访问所有其他模块提供的类,不然之前版本的库就用不了了。 -* **未命名模块:**我们自己创建的一个Java项目,如果没有创建`module-info.java`,那么会按照未命名模块进行处理,未命名模块同样可以访问所有其他模块提供的类,这样我们之前写的Java 8代码才能正常地在Java 9以及之后的版本下运行。不过,由于没有使用Java 9的模块新特性,未命名模块只能默认暴露给其他未命名的模块和自动模块,应用程序模块无法访问这些类(实际上就是传统Java 8以下的编程模式,因为没有模块只需要导包就行) - -这里我们就来创建两个项目,看看如何使用模块机制,首先我们在项目A中,添加一个User类,一会项目B需要用到: - -```java -package com.test; - -public class User { - String name; - int age; - - public User(String name, int age) { - this.name = name; - this.age = age; - } - - @Override - public String toString() { - return name+" ("+age+"岁)"; - } -} -``` - -接着我们编写一下项目A的模块设置: - -![image-20220528230842617](https://tva1.sinaimg.cn/large/e6c9d24egy1h2ois6anujj21mu09075b.jpg) - -这里我们将`com.test`包下所有内容都暴露出去,默认情况下所有的包都是私有的,就算其他项目将此项目作为依赖也无法使用。 - -接着我们现在想要在项目B中使用项目A的User类,我们需要进行导入: - -![image-20220528232033318](https://tva1.sinaimg.cn/large/e6c9d24egy1h2oj4ivmi3j21kw08ujsm.jpg) - -现在我们就可以在Main类中使用模块`module.a`中暴露出来的包内容了: - -```java -import com.test.User; //如果模块module.a不暴露,那么将无法导入 - -public class Main { - public static void main(String[] args) { - User user = new User("lbw", 18); - System.out.println(user); - } -} -``` - -当然除了普通的`exports`进行包的暴露之外,我们也可以直接指定将包暴露给指定的模块: - -```java -module module.a { - exports com.test to module.b; //这里我们将com.test包暴露给指定的模块module.b,非指定的模块即使导入也无法使用 -} -``` - -不过现在还有一个问题,如果模块`module.a`依赖于其他模块,那么会不会传递给依赖于模块`module.a`的模块呢? - -```java -module module.a { - exports com.test to module.b; //使用exports将com.test包下所有内容暴露出去,这样其他模块才能导入 - requires java.logging; //这里添加一个模块的依赖 -} -``` - -![image-20220529103614788](https://tva1.sinaimg.cn/large/e6c9d24ely1h2p2njtdxyj21gu09iabi.jpg) - -可以看到,在模块`module.b`中,并没有进行依赖传递,说明哪个模块导入的依赖只能哪个模块用,但是现在我们希望依赖可以传递,就是哪个模块用了什么依赖,依赖此模块的模块也会自动进行依赖,我们可以通过一个关键字解决: - -```java -module module.a { - exports com.test to module.b; //使用exports将com.test包下所有内容暴露出去,这样其他模块才能导入 - requires transitive java.logging; //使用transitive来向其他模块传递此依赖 -} -``` - -现在就可以使用了: - -![image-20220529103828560](https://tva1.sinaimg.cn/large/e6c9d24ely1h2p2pvizv2j21cu0b275z.jpg) - -还有我们前面演示的反射,我们发现如果我们依赖了一个模块,是没办法直接进行反射操作的: - -```java -public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException { - User user = new User("AAA", 18); - Class userClass = User.class; - Field field = userClass.getDeclaredField("name"); - field.setAccessible(true); //尝试修改访问权限 - System.out.println(field.get(user)); -} -``` - -![image-20220529104451040](https://tva1.sinaimg.cn/large/e6c9d24ely1h2p2widxrbj22l608cwip.jpg) - -那么怎么样才可以使用反射呢?我们可以为其他模块开放某些运行使用反射的类: - -```java -open module module.a { //直接添加open关键字开放整个模块的反射权限 - exports com.test to module.b; -} -``` - -```java -module module.a { - exports com.test to module.b; - opens com.test; //通过使用opens关键字来为其他模块开放反射权限 - //也可以指定目标开放反射 opens com.test to module.b; -} -``` - -我们还可以指定模块需要使用的抽象类或是接口实现: - -```java -package com.test; - -public interface Test { -} -``` - -```java -open module module.a { - exports com.test to module.b; - uses com.test.Test; //使用uses指定,Test是一个接口(比如需要的服务等),模块需要使用到 -} -``` - -我们可以在模块B中去实现一下,然后声明我们提供了实现类: - -```java -package com.main; - -import com.test.Test; - -public class TestImpl implements Test { - -} -``` - -```java -module module.b { - requires module.a; //导入项目A的模块,此模块暴露了com.test包 - provides com.test.Test with com.main.TestImpl; //声明此模块提供了Test的实现类 -} -``` - -了解了以上的相关知识后,我们就可以简单地进行模块的使用了。比如现在我们创建了一个新的Maven项目: - -![image-20220529112208486](https://tva1.sinaimg.cn/large/e6c9d24ely1h2p3zb45vqj225w0qowlb.jpg) - -然后我们导入了lombok框架的依赖,如果我们不创建`module-info.java`文件,那么就是一个未命名模块,未命名模块默认可以使用其他所有模块提供的类,实际上就是我们之前的开发模式: - -```java -package com.test; - -import lombok.extern.java.Log; - -@Log -public class Main { - public static void main(String[] args) { - log.info("Hello World!"); //使用lombok提供的注解,可以正常运行 - } -} -``` - -现在我们希望按照全新的模块化开发模式来进行开发,将我们的项目从未命名模块改进为应用程序模块,所以我们先创建好`module-info.java`文件: - -```java -module com.test { -} -``` - -可以看到,直接报错了: - -![image-20220529112707958](https://tva1.sinaimg.cn/large/e6c9d24ely1h2p44huvl1j21tk0bcmzz.jpg) - -明明导入了lombok依赖,却无法使用,这是因为我们还需要去依赖对应的模块才行: - -```java -module com.test { - requires lombok; //lombok模块 - requires java.logging; //JUL日志模块,也需要使用到 -} -``` - -![image-20220529112909452](https://tva1.sinaimg.cn/large/e6c9d24ely1h2p46lqnndj21au0fu0ug.jpg) - -这样我们就可以正常使用了,之后为了教程演示方便,咱们还是不用模块。 - -### JShell交互式编程 - -Java 9为我们通过了一种交互式编程工具JShell,你还别说,真有Python那味。 - -![image-20220529141547082](https://tva1.sinaimg.cn/large/e6c9d24egy1h2p8zzjlfdj20v204yaaf.jpg) - -环境配置完成后,我们只需要输入`jshell`命令即可开启交互式编程了,它支持我们一条一条命令进行操作。 - -比如我们来做一个简单的计算: - -![image-20220529141719363](https://tva1.sinaimg.cn/large/e6c9d24egy1h2p91l5635j212609qt8z.jpg) - -我们一次输入一行(可以不加分号),先定义一个a=10和b=10,然后定义c并得到a+b的结果,可以看到还是非常方便的,但是注意语法还是和Java是一样的。 - -![image-20220529141954494](https://tva1.sinaimg.cn/large/e6c9d24egy1h2p949kt5nj20ze06sdg8.jpg) - -我们也可以快速创建一个方法供后续的调用。当我们按下Tab键还可以进行自动补全: - -![image-20220529142340030](https://tva1.sinaimg.cn/large/e6c9d24egy1h2p986jzkdj215608074z.jpg) - -除了直接运行我们写进去的代码之外,它还支持使用命令,输入`help`来查看命令列表: - -![image-20220529142440584](https://tva1.sinaimg.cn/large/e6c9d24egy1h2p998buwcj217m0a4t9r.jpg) - -比如我们可以使用`/vars`命令来展示当前定义的变量列表: - -![image-20220529142757286](https://tva1.sinaimg.cn/large/e6c9d24egy1h2p9cn6p6qj20wy042t8s.jpg) - -当我们不想使用jshell时,直接输入`/exit`退出即可: - -![image-20220529142852920](https://tva1.sinaimg.cn/large/e6c9d24egy1h2p9dlqiucj20rw03274b.jpg) - -### 接口中的private方法 - -在Java 8中,接口中 的方法支持添加`default`关键字来添加默认实现: - -```java -public interface Test { - default void test(){ - System.out.println("我是test方法默认实现"); - } -} -``` - -而在Java 9中,接口再次得到强化,现在接口中可以存在私有方法了: - -```java -public interface Test { - default void test(){ - System.out.println("我是test方法默认实现"); - this.inner(); //接口中方法的默认实现可以直接调用接口中的私有方法 - } - - private void inner(){ //声明一个私有方法 - System.out.println("我是接口中的私有方法!"); - } -} -``` - -注意私有方法必须要提供方法体,因为权限为私有的,也只有这里能进行方法的具体实现了,并且此方法只能被接口中的其他私有方法或是默认实现调用。 - -### 集合类新增工厂方法 - -在之前,如果我们想要快速创建一个Map只能: - -```java -public static void main(String[] args) { - Map map = new HashMap<>(); //要快速使用Map,需要先创建一个Map对象,然后再添加数据 - map.put("AAA", 19); - map.put("BBB", 23); - - System.out.println(map); -} -``` - -而在Java 9之后,我们可以直接通过`of`方法来快速创建了: - -```java -public static void main(String[] args) { - Map map = Map.of("AAA", 18, "BBB", 20); //直接一句搞定 - - System.out.println(map); -} -``` - -是不是感觉非常方便,of方法还被重载了很多次,分别适用于快速创建包含0~10对键值对的Map: - -![image-20220529144905646](https://tva1.sinaimg.cn/large/e6c9d24egy1h2p9ymylq6j21k00ggact.jpg) - -但是注意,通过这种方式创建的Map和通过Arrays创建的List比较类似,也是无法进行修改的。 - -当然,除了Map之外,其他的集合类都有相应的`of`方法: - -```java -public static void main(String[] args) { - Set set = Set.of("BBB", "CCC", "AAA"); //注意Set中元素顺序并不一定你的添加顺序 - List list = List.of("AAA", "CCC", "BBB"); //好耶,再也不用Arrays了 -} -``` - -### 改进的 Stream API - -还记得我们之前在JavaSE中学习的Stream流吗?当然这里不是指进行IO操作的流,而是JDK1.8新增的Stream API,通过它大大方便了我们的编程。 - -```java -public static void main(String[] args) { - Stream - .of("A", "B", "B", "C") //这里我们可以直接将一些元素封装到Stream中 - .filter(s -> s.equals("B")) //通过过滤器过滤 - .distinct() //去重 - .forEach(System.out::println); //最后打印 -} -``` - -自从有了Stream,我们对于集合的一些操作就大大地简化了,对集合中元素的批量处理,只需要在Stream中一气呵成(具体的详细操作请回顾JavaSE篇) - -如此方便的框架,在Java 9得到了进一步的增强: - -```java -public static void main(String[] args) { - Stream - .of(null) //如果传入null会报错 - .forEach(System.out::println); - - Stream - .ofNullable(null) //使用新增的ofNullable方法,这样就不会了,不过这样的话流里面就没东西了 - .forEach(System.out::println); -} -``` - -还有,我们可以通过迭代快速生成一组数据(实际上Java 8就有了,这里新增的是允许结束迭代的): - -```java -public static void main(String[] args) { - Stream - .iterate(0, i -> i + 1) //Java8只能像这样生成无限的流,第一个参数是种子,就是后面的UnaryOperator的参数i一开始的值,最后会返回一个值作为i的新值,每一轮都会执行UnaryOperator并生成一个新值到流中,这个是源源不断的,如果不加limit()进行限制的话,将无限生成下去。 - .limit(20) //这里限制生成20个 - .forEach(System.out::println); -} -``` - -```java -public static void main(String[] args) { - Stream - //不知道怎么写?参考一下:for (int i = 0;i < 20;i++) - .iterate(0, i -> i < 20, i -> i + 1) //快速生成一组0~19的int数据,中间可以添加一个断言,表示什么时候结束生成 - .forEach(System.out::println); -} -``` - -Stream还新增了对数据的截断操作,比如我们希望在读取到某个元素时截断,不再继续操作后面的元素: - -```java -public static void main(String[] args) { - Stream - .iterate(0, i -> i + 1) - .limit(20) - .takeWhile(i -> i < 10) //当i小于10时正常通过,一旦大于等于10直接截断 - .forEach(System.out::println); -} -``` - -```java -public static void main(String[] args) { - Stream - .iterate(0, i -> i + 1) - .limit(20) - .dropWhile(i -> i < 10) //和上面相反,上来就是截断状态,只有当满足条件时再开始通过 - .forEach(System.out::println); -} -``` - -### 其他小型变动 - -Try-with-resource语法现在不需要再完整的声明一个变量了,我们可以直接将现有的变量丢进去: - -```java -public static void main(String[] args) throws IOException { - InputStream inputStream = Files.newInputStream(Paths.get("pom.xml")); - try (inputStream) { //单独丢进try中,效果是一样的 - for (int i = 0; i < 100; i++) - System.out.print((char) inputStream.read()); - } -} -``` - -在Java 8中引入了Optional类,它很好的解决了判空问题: - -```java -public static void main(String[] args) throws IOException { - test(null); -} - -public static void test(String s){ - //比如现在我们想执行 System.out.println(str.toLowerCase()) - //但是由于我们不清楚给进来的str到底是不是null,如果是null的话会引起空指针异常 - //但是去单独进行一次null判断写起来又不太简洁,这时我们可以考虑使用Optional进行包装 - Optional - .ofNullable(s) - .ifPresent(str -> System.out.println(str.toLowerCase())); -} -``` - -这种写法就有点像Kotlin或是JS中的语法: - -```kotlin -fun main() { - test(null) -} - -fun test(str : String?){ //传入的String对象可能为null,这里类型写为String? - println(str?.lowercase()) // ?.表示只有不为空才进行调用 -} -``` - -在Java 9新增了一些更加方便的操作: - -```java -public static void main(String[] args) { - String str = null; - Optional.ofNullable(str).ifPresentOrElse(s -> { //通过使用ifPresentOrElse,我们同时处理两种情况 - System.out.println("被包装的元素为:"+s); //第一种情况和ifPresent是一样的 - }, () -> { - System.out.println("被包装的元素为null"); //第二种情况是如果为null的情况 - }); -} -``` - -我们也可以使用`or()`方法快速替换为另一个Optional类: - -```java -public static void main(String[] args) { - String str = null; - Optional.ofNullable(str) - .or(() -> Optional.of("AAA")) //如果当前被包装的类不是null,依然返回自己,但是如果是null,那就返回Supplier提供的另一个Optional包装 - .ifPresent(System.out::println); -} -``` - -当然还支持直接转换为Stream,这里就不多说了。 - -在Java 8及之前,匿名内部类是没办法使用钻石运算符进行自动类型推断的: - -```java -public abstract class Test{ //这里我们写一个泛型类 - public T t; - - public Test(T t) { - this.t = t; - } - - public abstract T test(); -} -``` - -```java -public static void main(String[] args) throws IOException { - Test test = new Test<>("AAA") { //在低版本这样写是会直接报错的,因为匿名内部类不支持自动类型推断,但是很明显我们这里给的参数是String类型的,所以明明有机会进行类型推断,却还是要我们自己填类型,就很蠢 - //在Java 9之后,这样的写法终于可以编译通过了 - @Override - public String test() { - return t; - } - }; -} -``` - -当然除了以上的特性之外还有Java 9的多版本JAR包支持、CompletableFuture API的改进等,因为不太常用,这里就不做介绍了。 - -## Java 10 新特性 - -Java 10主要带来的是一些内部更新,相比Java 9带来的直观改变不是很多,其中比较突出的就是局部变量类型推断了。 - -### 局部变量类型推断 - -在Java中,我们可以使用自动类型推断: - -```java -public static void main(String[] args) { - // String a = "Hello World!"; 之前我们定义变量必须指定类型 - var a = "Hello World!"; //现在我们使用var关键字来自动进行类型推断,因为完全可以从后面的值来判断是什么类型 -} -``` - -但是注意,`var`关键字必须位于有初始值设定的变量上,否则鬼知道你要用什么类型。 - -![image-20220529171216795](https://tva1.sinaimg.cn/large/e6c9d24egy1h2pe3mpaw5j210u054jrw.jpg) - -我们来看看是不是类型也能正常获取: - -```java -public static void main(String[] args) { - var a = "Hello World!"; - System.out.println(a.getClass()); -} -``` - -这里虽然是有了var关键字进行自动类型推断,但是最终还是会变成String类型,得到的Class也是String类型。但是Java终究不像JS那样进行动态推断,这种类型推断仅仅发生在编译期间,到最后编译完成后还是会变成具体类型的: - -![image-20220529170538383](https://tva1.sinaimg.cn/large/e6c9d24egy1h2pdwq4g2aj218e0b4jsh.jpg) - -并且`var`关键字仅适用于局部变量,我们是没办法在其他地方使用的,比如类的成员变量: - -![image-20220529171444062](https://tva1.sinaimg.cn/large/e6c9d24egy1h2pe66iavlj216y0963z1.jpg) - -有关Java 10新增的一些其他改进,这里就不提了。 - -## Java 11 新特性 - -Java 11 是继Java 8之后的又一个TLS长期维护版本,在Java 17出现之前,一直都是此版本作为广泛使用的版本,其中比较关键的是用于Lambda的形参局部变量语法。 - -### 用于Lambda的形参局部变量语法 - -在Java 10我们认识了`var`关键字,它能够直接让局部变量自动进行类型推断,不过它不支持在lambda中使用: - -![image-20220529235822891](https://tva1.sinaimg.cn/large/e6c9d24egy1h2ppu61uu4j211w05m3z0.jpg) - -但是实际上这里是完全可以进行类型推断的,所以在Java 11,终于是支持了,这样编写就不会报错了: - -![image-20220529235935071](https://tva1.sinaimg.cn/large/e6c9d24egy1h2ppvf70n0j213g05m3yu.jpg) - -### 针对于String类的方法增强 - -在Java 11为String新增一些更加方便的操作: - -```java -public static void main(String[] args) { - var str = "AB\nC\nD"; - System.out.println(str.isBlank()); //isBlank方法用于判断是否字符串为空或者是仅包含空格 - str - .lines() //根据字符串中的\n换行符进行切割,分为多个字符串,并转换为Stream进行操作 - .forEach(System.out::println); -} -``` - -我们还可以通过`repeat()`方法来让字符串重复拼接: - -```java -public static void main(String[] args) { - String str = "ABCD"; //比如现在我们有一个ABCD,但是现在我们想要一个ABCDABCD这样的基于原本字符串的重复字符串 - System.out.println(str.repeat(2)); //一个repeat就搞定了 -} -``` - -我们也可以快速地进行空格去除操作: - -```java -public static void main(String[] args) { - String str = " A B C D "; - System.out.println(str.strip()); //去除首尾空格 - System.out.println(str.stripLeading()); //去除首部空格 - System.out.println(str.stripTrailing()); //去除尾部空格 -} -``` - -### 全新的HttpClient使用 - -在Java 9的时候其实就已经引入了全新的Http Client API,用于取代之前比较老旧的HttpURLConnection类,新的API支持最新的HTTP2和WebSocket协议。 - -```java -public static void main(String[] args) throws URISyntaxException, IOException, InterruptedException { - HttpClient client = HttpClient.newHttpClient(); //直接创建一个新的HttpClient - //现在我们只需要构造一个Http请求实体,就可以让客户端帮助我们发送出去了(实际上就跟浏览器访问类似) - HttpRequest request = HttpRequest.newBuilder().uri(new URI("https://www.baidu.com")).build(); - //现在我们就可以把请求发送出去了,注意send方法后面还需要一个响应体处理器(内置了很多)这里我们选择ofString直接吧响应实体转换为String字符串 - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - //来看看响应实体是什么吧 - System.out.println(response.body()); -} -``` - -利用全新的客户端,我们甚至可以轻松地做一个爬虫(仅供学习使用,别去做违法的事情,爬虫玩得好,牢饭吃到饱),比如现在我们想去批量下载某个网站的壁纸: - -![image-20220530112549225](https://tva1.sinaimg.cn/large/e6c9d24egy1h2q9pg4bvdj22920jcq83.jpg) - -网站地址:https://pic.netbian.com/4kmeinv/ - -我们随便点击一张壁纸,发现网站的URL格式为: - -![image-20220530112701156](https://tva1.sinaimg.cn/large/e6c9d24egy1h2q9qp1af0j220s0loaf5.jpg) - -并且不同的壁纸似乎都是这样:https://pic.netbian.com/tupian/数字.html,好了差不多可以开始整活了: - -```java -public static void main(String[] args) throws URISyntaxException, IOException, InterruptedException { - HttpClient client = HttpClient.newHttpClient(); - for (int i = 0; i < 10; i++) { //先不要一次性获取太多,先来10个 - HttpRequest request = HttpRequest.newBuilder().uri(new URI("https://pic.netbian.com/tupian/"+(29327 + i)+".html")).build(); //这里我们按照规律,批量获取 - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - System.out.println(response.body()); //这里打印一下看看网页 - } -} -``` - -可以看到,最后控制台成功获取到这些图片的网站页面了: - -![image-20220530113039571](https://tva1.sinaimg.cn/large/e6c9d24egy1h2q9uh6barj21k40b40w3.jpg) - -接着我们需要来观察一下网站的HTML具体怎么写的,把图片的地址提取出来: - -![image-20220530113136156](https://tva1.sinaimg.cn/large/e6c9d24egy1h2q9vgs183j22b60esdmg.jpg) - -好了,知道图片在哪里就好办了,直接字符串截取: - -```java -public static void main(String[] args) throws URISyntaxException, IOException, InterruptedException { - HttpClient client = HttpClient.newHttpClient(); - for (int i = 0; i < 10; i++) { - ... - String html = response.body(); - - String prefix = " imageResponse = client.send(imageRequest, HttpResponse.BodyHandlers.ofInputStream()); - //拿到输入流和文件输出流 - InputStream imageInput = imageResponse.body(); - FileOutputStream stream = new FileOutputStream("images/"+i+".jpg"); //一会要保存的格式 - try (stream;imageInput){ //直接把要close的变量放进来就行,简洁一些了 - int size; //下面具体保存过程的不用我多说了吧 - byte[] data = new byte[1024]; - while ((size = imageInput.read(data)) > 0) { - stream.write(data, 0, size); - } - } - } -} -``` - -我们现在来看看效果吧,美女的图片已经成功保存到本地了: - -![image-20220530114824605](https://tva1.sinaimg.cn/large/e6c9d24egy1h2qacy37g6j22bs0psjwj.jpg) - -当然,这仅仅是比较简单的爬虫,不过我们的最终目的还是希望各位能够学会使用新的HttpClient API。 - -## Java 12-16 新特性 - -由于Java版本的更新迭代速度自Java 9开始为半年更新一次(Java 8到Java 9隔了整整三年),所以各个版本之间的更新内容比较少,剩余的6个版本,我们就多个版本放在一起进行讲解了。 - -![image-20220530120729757](https://tva1.sinaimg.cn/large/e6c9d24egy1h2qawt5acvj21ci0u0tcg.jpg) - -Java12-16这五个版本并非长期支持版本,所以很多特性都是一种处于实验性功能,12/13版本引入了一些实验性功能,并根据反馈进行调整,最后在后续版本中正式开放使用,其实就是体验服的那种感觉。 - -### 新的switch语法 - -在Java 12引入全新的switch语法,让我们使用switch语句更加的灵活,比如我们想要编写一个根据成绩得到等级的方法: - -```java -/** - * 传入分数(范围 0 - 100)返回对应的等级: - * 100-90:优秀 - * 70-80:良好 - * 60-70:及格 - * 0-60:寄 - * @param score 分数 - * @return 等级 - */ -public static String grade(int score){ - -} -``` - -现在我们想要使用switch来实现这个功能(不会吧不会吧,不会有人要想半天怎么用switch实现吧),之前的写法是: - -```java -public static String grade(int score){ - score /= 10; //既然分数段都是整数,那就直接整除10 - String res = null; - switch (score) { - case 10: - case 9: - res = "优秀"; //不同的分数段就可以返回不同的等级了 - break; //别忘了break,不然会贯穿到后面 - case 8: - case 7: - res = "良好"; - break; - case 6: - res = "及格"; - break; - default: - res = "不及格"; - break; - } - return res; -} -``` - -但是现在我们可以使用新的特性了: - -```java -public static String grade(int score){ - score /= 10; //既然分数段都是整数,那就直接整除10 - return switch (score) { //增强版switch语法 - case 10, 9 -> "优秀"; //语法那是相当的简洁,而且也不需要我们自己考虑break或是return来结束switch了(有时候就容易忘记,这样的话就算忘记也没事了) - case 8, 7 -> "良好"; - case 6 -> "及格"; - default -> "不及格"; - }; -} -``` - -不过最后编译出来的样子,貌似还是和之前是一样的: - -![image-20220530222918174](https://tva1.sinaimg.cn/large/e6c9d24egy1h2qsvu3u7dj21zw0nawhc.jpg) - -这种全新的switch语法称为`switch表达式`,它的意义不仅仅体现在语法的精简上,我们来看看它的详细规则: - -```java -var res = switch (obj) { //这里和之前的switch语句是一样的,但是注意这样的switch是有返回值的,所以可以被变量接收 - case [匹配值, ...] -> "优秀"; //case后直接添加匹配值,匹配值可以存在多个,需要使用逗号隔开,使用 -> 来返回如果匹配此case语句的结果 - case ... //根据不同的分支,可以存在多个case - default -> "不及格"; //注意,表达式要求必须涵盖所有的可能,所以是需要添加default的 -}; -``` - -那么如果我们并不是能够马上返回,而是需要做点什么其他的工作才能返回结果呢? - -```java -var res = switch (obj) { //增强版switch语法 - case [匹配值, ...] -> "优秀"; - default -> { //我们可以使用花括号来将整套逻辑括起来 - //... 我是其他要做的事情 - yield "不及格"; //注意处理完成后需要返回最终结果,但是这样并不是使用return,而是yield关键字 - } -}; -``` - -当然,也可以像这样: - -```java -var res = switch (args.length) { //增强版switch语法 - case [匹配值, ...]: - yield "AAA"; //传统的:写法,通过yield指定返回结果,同样不需要break - default: - System.out.println("默认情况"); - yield "BBB"; -}; -``` - -这种全新的语法,可以说极大地方便了我们的编码,不仅代码简短,而且语义明确。唯一遗憾的是依然不支持区间匹配。 - -**注意:**switch表达式在Java 14才正式开放使用,所以我们项目的代码级别需要调整到14以上。 - -### 文本块 - -如果你学习过Python,一定知道三引号: - -```python -#当我们需要使用复杂字符串时,可能字符串中包含了很多需要转义的字符,比如双引号等,这时我们就可以使用三引号来囊括字符串 -multi_line = """ - nice to meet you! - nice to meet you! - nice to meet you! - """ -print multi_line -``` - -没错,Java13也带了这样的特性,旨在方便我们编写复杂字符串,这样就不用再去用那么多的转义字符了: - -![image-20220530230225037](https://tva1.sinaimg.cn/large/e6c9d24egy1h2qtu8ym7pj218008w3z2.jpg) - -可以看到,Java中也可以使用这样的三引号来表示字符串了,并且我们可以随意在里面使用特殊字符,包括双引号等,但是最后编译出来的结果实际上还是会变成一个之前这样使用了转义字符的字符串: - -![image-20220530230343933](https://tva1.sinaimg.cn/large/e6c9d24egy1h2qtvm8z1wj21e008kdgm.jpg) - -仔细想想,这样我们写SQL或是HTML岂不是就舒服多了? - -**注意:**文本块表达式在Java 15才正式开放使用,所以我们项目的代码级别需要调整到15以上。 - -### 新的instanceof语法 - -在Java 14,instanceof迎来了一波小更新(哈哈,这版本instanceof又加强了,版本强势语法) - -比如我们之前要重写一个类的equals方法: - -```java -public class Student { - private final String name; - - public Student(String name) { - this.name = name; - } - - @Override - public boolean equals(Object obj) { - if(obj instanceof Student) { //首先判断是否为Student类型 - Student student = (Student) obj; //如果是,那么就类型转换 - return student.name.equals(this.name); //最后比对属性是否一样 - } - return false; - } -} -``` - -在之前我们一直都是采用这种先判断类型,然后类型转换,最后才能使用的方式,但是这个版本instanceof加强之后,我们就不需要了,我们可以直接将student替换为模式变量: - -![image-20220530232252253](https://tva1.sinaimg.cn/large/e6c9d24egy1h2qufj2gt9j21da0bowg5.jpg) - -```java -public class Student { - private final String name; - - public Student(String name) { - this.name = name; - } - - @Override - public boolean equals(Object obj) { - if(obj instanceof Student student) { //在比较完成的屁股后面,直接写变量名字,而这个变量就是类型转换之后的 - return student.name.equals(this.name); //下面直接用,是不是贼方便 - } - return false; - } -} -``` - -在使用`instanceof`判断类型成立后,会自动强制转换类型为指定类型,简化了我们手动转换的步骤。 - -**注意:**新的instanceof语法在Java 16才正式开放使用,所以我们项目的代码级别需要调整到16以上。 - -### 空指针异常的改进 - -相信各位小伙伴在调试代码时,经常遇到空指针异常,比如下面的这个例子: - -```java -public static void test(String a, String b){ - int length = a.length() + b.length(); //可能给进来的a或是b为null - System.out.println(length); -} -``` - -那么为空时,就会直接: - -![image-20220530232755797](https://tva1.sinaimg.cn/large/e6c9d24egy1h2qukskyh6j21a602mgm0.jpg) - -但是由于我们这里a和b都调用了`length()`方法,虽然空指针异常告诉我们问题出现在这一行,但是到底是a为null还是b为null呢?我们是没办法直接得到的(遇到过这种问题的扣个1吧,只能调试,就很头疼) - -但是当我们在Java 14或更高版本运行时: - -![image-20220530233031005](https://tva1.sinaimg.cn/large/e6c9d24egy1h2qunhkgrcj21lc042my7.jpg) - -这里会明确指出是哪一个变量调用出现了空指针,是不是感觉特别人性化。 - -### 记录类型 - -继类、接口、枚举、注解之后的又一新类型来了,它的名字叫"记录",在Java 14中首次出场,这一出场,Lombok的噩梦来了。 - -在实际开发中,很多的类仅仅只是充当一个实体类罢了,保存的是一些不可变数据,比如我们从数据库中查询的账户信息,最后会被映射为一个实体类: - -```java -@Data -public class Account { //使用Lombok,一个注解就搞定了 - String username; - String password; -} -``` - -Lombok可以说是简化代码的神器了,他能在编译时自动生成getter和setter、构造方法、toString()方法等实现,在编写这些实体类时,简直不要太好用,而这一波,官方也是看不下去了,于是自己也搞了一个记录类型。 - -记录类型本质上也是一个普通的类,不过是final类型且继承自java.lang.Record抽象类的,它会在编译时,会自动编译出 `public get` `hashcode` 、`equals`、`toString` 等方法,好家伙,这是要逼死Lombok啊。 - -```java -public record Account(String username, String password) { //直接把字段写在括号中 - -} -``` - -使用起来也是非常方便,自动生成了构造方法和成员字段的公共get方法: - -![image-20220530235609885](https://tva1.sinaimg.cn/large/e6c9d24egy1h2qve5yx6tj219u086ab5.jpg) - -并且toString也是被重写了的: - -![image-20220530235719341](https://tva1.sinaimg.cn/large/e6c9d24egy1h2qvfdhp6oj219001s3yo.jpg) - -`equals()`方法仅做成员字段之间的值比较,也是帮助我们实现好了的: - -```java -Account account0 = new Account("Admin", "123456"); -Account account1 = new Account("Admin", "123456"); //两个属性都是一模一样的 -System.out.println(account0.equals(account1)); //得到true -``` - -是不是感觉这种类型就是专门为这种实体类而生的。 - -```java -public record Account(String username, String password) implements Runnable { //支持实现接口,但是不支持继承,因为继承的坑位已经默认被占了 - - @Override - public void run() { - - } -} -``` - -**注意:**记录类型在Java 16才正式开放使用,所以我们项目的代码级别需要调整到16以上。 - -## Java 17 新特性 - -Java 17作为新的LTS长期维护版本,我们来看看都更新了什么(不包含预览特性,包括switch第二次增强,哈哈,果然还是强度不够,都连续加强两个版本了) - -### 密封类型 - -密封类型可以说是Java 17正式推出的又一重磅类型,它在Java 15首次提出并测试了两个版本。 - -在Java中,我们可以通过继承(extends关键字)来实现类的能力复用、扩展与增强。但有的时候,可能并不是所有的类我们都希望能够被继承。所以,我们需要对继承关系有一些限制的控制手段,而密封类的作用就是**限制类的继承**。 - -实际上在之前我们如果不希望别人继承我们的类,可以直接添加`final`关键字: - -```java -public final class A{ //添加final关键字后,不允许对此类继承 - -} -``` - -这样有一个缺点,如果添加了`final`关键字,那么无论是谁,包括我们自己也是没办法实现继承的,但是现在我们有一个需求,只允许我们自己写的类继承A,但是不允许别人写的类继承A,这时该咋写?在Java 17之前想要实现就很麻烦。 - -但是现在我们可以使用密封类型来实现这个功能: - -```java -public sealed class A permits B{ //在class关键字前添加sealed关键字,表示此类为密封类型,permits后面跟上允许继承的类型,多个子类使用逗号隔开 - -} -``` - -密封类型有以下要求: - -* 可以基于普通类、抽象类、接口,也可以是继承自其他接抽象类的子类或是实现其他接口的类等。 -* 必须有子类继承,且不能是匿名内部类或是lambda的形式。 -* `sealed`写在原来`final`的位置,但是不能和`final`、`non-sealed`关键字同时出现,只能选择其一。 -* 继承的子类必须显式标记为`final`、`sealed`或是`non-sealed`类型。 - -标准的声明格式如下: - -```java -public sealed [abstract] [class/interface] 类名 [extends 父类] [implements 接口, ...] permits [子类, ...]{ - //里面的该咋写咋写 -} -``` - -注意子类格式为: - -```java -public [final/sealed/non-sealed] class 子类 extends 父类 { //必须继承自父类 - //final类型:任何类不能再继承当前类,到此为止,已经封死了。 - //sealed类型:同父类,需要指定由哪些类继承。 - //non-sealed类型:重新开放为普通类,任何类都可以继承。 -} -``` - -比如现在我们写了这些类: - -```java -public sealed class A permits B{ //指定B继承A - -} -``` - -```java -public final class B extends A { //在子类final,彻底封死 - -} -``` - -我们可以看到其他的类无论是继承A还是继承B都无法通过编译: - -![image-20220531090136485](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rb5p7u49j216i05074q.jpg) - -![image-20220531090152743](https://tva1.sinaimg.cn/large/e6c9d24ely1h2rb5zg837j2130050jrz.jpg) - -但是如果此时我们主动将B设定为`non-sealed`类型: - -```java -public non-sealed class B extends A { - -} -``` - -这样就可以正常继承了,因为B指定了`non-sealed`主动放弃了密封特性,这样就显得非常灵活了。 - -当然我们也可以通过反射来获取类是否为密封类型: - -```java -public static void main(String[] args) { - Class a = A.class; - System.out.println(a.isSealed()); //是否为密封 -} -``` - -至此,Java 9 - 17的主要新特性就讲解完毕了。 diff --git a/青空笔记/JavaSSM笔记/Java SSM笔记(一).md b/青空笔记/JavaSSM笔记/Java SSM笔记(一).md deleted file mode 100644 index b20f8f1..0000000 --- a/青空笔记/JavaSSM笔记/Java SSM笔记(一).md +++ /dev/null @@ -1,2678 +0,0 @@ -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fpic1.zhimg.com%2Fv2-0e96436a999ac26218fc54344799c859_1200x500.jpg&refer=http%3A%2F%2Fpic1.zhimg.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1640617321&t=806572e49f8f6deafd8486b19af6d192) - -**建议:**对Java开发还不是很熟悉的同学,最好先花费半个月到一个月时间大量地去编写小项目,不推荐一口气学完,后面的内容相比前面的内容几乎是降维打击,一口气学完很容易忘记之前所学的基础知识,尤其是JavaSE阶段的内容。 - -# Spring框架技术 - -恭喜各位顺利进入到SSM(Spring+SpringMVC+Mybatis)阶段的学习,也算是成功出了Java新手村,由于前面我们已经学习过Mybatis了,因此,本期教程的时间安排相比之前会更短一些。从这里开始,很多的概念理解起来就稍微有一点难度了,因为你们没有接触过企业开发场景,很难体会到那种思想带来的好处,甚至到后期接触到的几乎都是基于云计算和大数据理论实现的框架(当下最热门最前沿的技术)逐渐不再是和计算机基础相关联,而是和怎么高效干活相关了。 - -在JavaWeb阶段,我们已经学习了如何使用Java进行Web应用程序开发,我们现在已经具有搭建Web网站的能力,但是,我们在开发的过程中,发现存在诸多的不便,在最后的图书管理系统编程实战中,我们发现虽然我们思路很清晰,知道如何编写对应的接口,但是这样的开发效率,实在是太慢了,并且对于对象创建的管理,存在诸多的不妥之处,因此,我们要去继续学习更多的框架技术,来简化和规范我们的Java开发。 - -Spring就是这样的一个框架(文档:https://docs.spring.io/spring-framework/docs/5.2.13.RELEASE/spring-framework-reference/),它就是为了简化开发而生,它是轻量级的**IoC**和**AOP**的容器框架,主要是针对**JavaBean**的生命周期进行管理的轻量级容器,并且它的生态已经发展得极为庞大。那么,首先一问,什么是**IoC**和**AOP**,什么又是**JavaBean**呢?只是听起来满满的高级感,实际上没有多高级(很多东西都是这样,名字听起来很牛,实际上只是一个很容易理解的东西) - -因此,一切的一切,我们还要从JavaBean说起,从这颗豆子生根发芽开始。 - -## 什么是JavaBean - -JavaBean就是有一定规范的Java实体类,跟普通类差不多,不同的是类内部提供了一些公共的方法以便外界对该对象内部属性进行操作,比如set、get操作,实际上,就是我们之前一直在用的: - -```java -public class User{ - private String name; - private int age; - public String getName(){ - return name; - } - public String getAge(){ - return age; - } - public void setName(String name){ - this.name = name; - } - public void setAge(int age){ - this.age = age; - } -} -``` - -它的所有属性都是private,所有的属性都可以通过get/set方法进行访问,同时还需要有一个无参构造(默认就有) - -因此我们之前编写的很多类,其实都可以是一个JavaBean。 - -## IoC理论基础 - -在我们之前的图书管理系统Web应用程序中,我们发现,整个程序其实是依靠各个部分相互协作,共同完成一个操作,比如要展示借阅信息列表,那么首先需要使用Servlet进行请求和响应的数据处理,然后请求的数据全部交给对应的Service(业务层)来处理,当Service发现要从数据库中获取数据时,再向对应的Mapper发起请求。 - -它们之间就像连接在一起的齿轮,谁也离不开谁: - -![img](https://images0.cnblogs.com/blog/281227/201305/30130748-488045b61d354b019a088b9cb7fc2d73.png) - -就像一个团队,每个人的分工都很明确,流水线上的一套操作必须环环相扣,这是一种高度耦合的体系。 - -虽然这样的体系逻辑非常清晰,整个流程也能够让人快速了解,但是这样存在一个很严重的问题,我们现在的时代实际上是一个软件项目高速迭代的时代,我们发现很多App三天两头隔三差五地就更新,而且是什么功能当下最火,就马不停蹄地进行跟进开发,因此,就很容易出现,之前写好的代码,实现的功能,需要全部推翻,改成新的功能,那么我们就不得不去修改某些流水线上的模块,但是这样一修改,会直接导致整个流水线的引用关系大面积更新。 - -就像我不想用这个Service实现类了,我想使用其他的实现类用不同的逻辑做这些功能,那么这个时候,我们只能每个类都去挨个进行修改,当项目特别庞大时,光是改个类名就够你改一天。 - -因此,高耦合度带来的缺点是很明显的,也是现代软件开发中很致命的问题。如果要改善这种情况,我们只能将各个模块进行解耦,让各个模块之间的依赖性不再那么地强。也就是说,Service的实现类,不再由我们决定,而是让程序自己决定,所有的实现类对象,全部交给程序来管理,所有对象之间的关系,也由程序来动态决定,这样就引入了IoC理论。 - -IOC是Inversion of Control的缩写,翻译为:“控制反转”,把复杂系统分解成相互合作的对象,这些对象类通过封装以后,内部实现对外部是透明的,从而降低了解决问题的复杂度,而且可以灵活地被重用和扩展。 - -![img](https://images0.cnblogs.com/blog/281227/201305/30131727-a8268fe6370049028078e6b8a1cbc88f.png) - -我们可以将对象交给IoC容器进行管理,比如当我们需要一个接口的实现时,由它根据配置文件来决定到底给我们哪一个实现类,这样,我们就可以不用再关心我们要去使用哪一个实现类了,我们只需要关心,给到我的一定是一个可以正常使用的实现类,能用就完事了,反正接口定义了啥,我只管调,这样,我们就可以放心地让一个人去写视图层的代码,一个人去写业务层的代码,开发效率那是高的一匹啊。 - -高内聚,低耦合,是现代软件的开发的设计目标,而Spring框架就给我们提供了这样的一个IoC容器进行对象的管理。 - -## 使用IoC容器 - -首先一定要明确,使用Spring首要目的是为了使得软件项目进行解耦,而不是为了去简化代码! - -Spring并不是一个独立的框架,它实际上包含了很多的模块: - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fwww.pianshen.com%2Fimages%2F58%2Fb64f717e0ba180c290cf75580c442132.JPEG&refer=http%3A%2F%2Fwww.pianshen.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1640690781&t=0b3f1818d8dd6c8810491bf8050ecd4e) - -而我们首先要去学习的就是Core Container,也就是核心容器模块。 - -Spring是一个非入侵式的框架,就像一个工具库一样,因此,我们只需要直接导入其依赖就可以使用了。 - -### 第一个Spring项目 - -我们创建一个新的Maven项目,并导入Spring框架的依赖,Spring框架的坐标: - -```xml - - org.springframework - spring-context - 5.3.13 - -``` - -接着在resource中创建一个Spring配置文件,命名为test.xml,直接右键点击即可创建: - -```xml - - - - -``` - -最后,在主方法中编写: - -```java -public static void main(String[] args) { - ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("text"); - -} -``` - -这样,一个最基本的Spring项目就创建完成了,接着我们来看看如何向IoC容器中注册JavaBean,首先创建一个Student类: - -```java -//注意,这里还用不到值注入,只需要包含成员属性即可,不用Getter/Setter。 -public class Student { - String name; - int age; -} -``` - -然后在配置文件中添加这个bean: - -```xml - -``` - -现在,这个对象不需要我们再去生成了,而是由IoC容器来提供: - -```java -public static void main(String[] args) { - ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("test.xml"); - Student student = (Student) context.getBean("student"); - System.out.println(student); -} -``` - -实际上,这里得到的Student对象是由Spring通过反射机制帮助我们创建的,初学者会非常疑惑,为什么要这样来创建对象,我们直接new一个它不香吗?为什么要交给IoC容器管理呢?在后面的学习中,我们再慢慢进行体会。 - -### 将JavaBean交给IoC容器管理 - -通过前面的例子,我们发现只要将我们创建好的JavaBean通过配置文件编写,即可将其交给IoC容器进行管理,那么,我们来看看,一个JavaBean的详细配置: - -```xml - -``` - -其中`name`属性(也可以是`id`属性),全局唯一,不可出现重复的名称,我们发现,之前其实就是通过Bean的名称来向IoC容器索要对应的对象,也可以通过其他方式获取。 - -我们现在在主方法中连续获取两个对象: - -```java -ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("test.xml"); -Student student = (Student) context.getBean("student"); -Student student2 = (Student) context.getBean("student"); -System.out.println(student); -System.out.println(student2); -``` - -我们发现两次获取到的实际上是同一个对象,也就是说,默认情况下,通过IoC容器进行管理的JavaBean是单例模式的,无论怎么获取始终为那一个对象,那么如何进行修改呢?只需要修改其作用域即可,添加`scope`属性: - -```xml - -``` - -通过将其设定为`prototype`(原型模式)来使得其每次都会创建一个新的对象。我们接着来观察一下,这两种模式下Bean的生命周期,我们给构造方法添加一个输出: - -```java -public class Student { - String name; - int age; - - public Student(){ - System.out.println("我被构造了!"); - } -} -``` - -接着我们在mian方法中打上断点来查看对象分别是在什么时候被构造的。 - -我们发现,当Bean的作用域为单例模式,那么它会在一开始就被创建,而处于原型模式下,只有在获取时才会被创建,也就是说,单例模式下,Bean会被IoC容器存储,只要容器没有被销毁,那么此对象将一直存在,而原型模式才是相当于直接new了一个对象,并不会被保存。 - -我们还可以通过配置文件,告诉创建一个对象需要执行此初始化方法,以及销毁一个对象的销毁方法: - -```java -public class Student { - String name; - int age; - - private void init(){ - System.out.println("我是初始化方法!"); - } - - private void destroy(){ - System.out.println("我是销毁方法!"); - } - - public Student(){ - System.out.println("我被构造了!"); - } -} -``` - -```java -public static void main(String[] args) { - ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("test.xml"); - Student student = (Student) context.getBean("student"); - System.out.println(student); - context.close(); //手动销毁容器 -} -``` - -最后在XML文件中编写配置: - -```xml - -``` - -接下来测试一下即可。 - -我们还可以手动指定Bean的加载顺序,若某个Bean需要保证一定在另一个Bean加载之前加载,那么就可以使用`depend-on`属性。 - -### 依赖注入DI - -现在我们已经了解了如何注册和使用一个Bean,那么,如何向Bean的成员属性进行赋值呢?也就是说,IoC在创建对象时,需要将我们预先给定的属性注入到对象中,非常简单,我们可以使用`property`标签来实现,但是一定注意,此属性必须存在一个set方法,否则无法赋值: - -```xml - - - -``` - -```java -public class Student { - String name; - int age; - - public void setName(String name) { - this.name = name; - } - - public void say(){ - System.out.println("我是:"+name); - } -} -``` - -最后测试是否能够成功将属性注入到我们的对象中: - -```java -public static void main(String[] args) { - ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("test.xml"); - Student student = (Student) context.getBean("student"); - student.say(); -} -``` - -那么,如果成员属性是一个非基本类型非String的对象类型,我们该怎么注入呢? - -```java -public class Card { -} -``` - -```java -public class Student { - String name; - int age; - Card card; - - public void setCard(Card card) { - this.card = card; - } - - public void setName(String name) { - this.name = name; - } - - public void say(){ - System.out.println("我是:"+name+",我都学生证:"+card); - } -} -``` - -我们只需要将对应的类型也注册为bean即可,然后直接使用`ref`属性来进行引用: - -```xml - - - - - -``` - -那么,集合如何实现注入呢?我们需要在`property`内部进行编写: - -```xml - - - - 100.0 - 95.0 - 92.5 - - - -``` - -现在,我们就可以直接以一个数组的方式将属性注入,注意如果是List类型的话,我们也可以使用`array`数组。同样的,如果是一个Map类型,我们也可以使用`entry`来注入: - -```java -public class Student { - String name; - int age; - Map map; - - public void setMap(Map map) { - this.map = map; - } - - public void say(){ - System.out.println("我的成绩:"+ map); - } -} -``` - -```xml - - - - - - - - - -``` - -我们还可以使用自动装配来实现属性值的注入: - -```xml - - -``` - -自动装配会根据set方法中需要的类型,自动在容器中查找是否存在对应类型或是对应名称以及对应构造方法的Bean,比如我们上面指定的为`byType`,那么其中的card属性就会被自动注入类型为Card的Bean - -我们已经了解了如何使用set方法来创建对象,那么能否不使用默认的无参构造方法,而是指定一个有参构造进行对象的创建呢?我们可以指定构造方法: - -```xml - - - - -``` - -```java -public class Student { - String name; - int age; - - public Student(String name, int age){ - this.name = name; - this.age = age; - } - - public void say(){ - System.out.println("我是:"+name+"今年"+age+"岁了!"); - } -} -``` - -通过手动指定构造方法参数,我们就可以直接告诉容器使用哪一个构造方法来创建对象。 - -*** - -## 面向切面AOP - -又是一个听起来很高大上的名词,AOP思想实际上就是:在运行时,动态地将代码切入到类的指定方法、指定位置上的编程思想就是面向切面的编程。也就是说,我们可以使用AOP来帮助我们在方法执行前或执行之后,做一些额外的操作,实际上,就是代理! - -通过AOP我们可以在保证原有业务不变的情况下,添加额外的动作,比如我们的某些方法执行完成之后,需要打印日志,那么这个时候,我们就可以使用AOP来帮助我们完成,它可以批量地为这些方法添加动作。可以说,它相当于将我们原有的方法,在不改变源代码的基础上进行了增强处理。 - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimage.mamicode.com%2Finfo%2F201808%2F20180810205057430646.png&refer=http%3A%2F%2Fimage.mamicode.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1640952533&t=2e5d4955d8f3436603622dd184da65d6) - -相当于我们的整个业务流程,被直接斩断,并在断掉的位置添加了一个额外的操作,再连接起来,也就是在一个切点位置插入内容。它的原理实际上就是通过动态代理机制实现的,我们在JavaWeb阶段已经给大家讲解过动态代理了。不过Spring底层并不是使用的JDK提供的动态代理,而是使用的第三方库实现,它能够以父类的形式代理,而不是接口。 - -### 使用SpringAOP - -Spring是支持AOP编程的框架之一(实际上它整合了AspectJ框架的一部分),要使用AOP我们需要先导入一个依赖: - -```xml - - org.springframework - spring-aspects - 5.3.13 - -``` - -那么,如何使用AOP呢?首先我们要明确,要实现AOP操作,我们需要知道这些内容: - -1. 需要切入的类,类的哪个方法需要被切入 -2. 切入之后需要执行什么动作 -3. 是在方法执行前切入还是在方法执行后切入 -4. 如何告诉Spring需要进行切入 - -那么我们依次来看,首先需要解决的问题是,找到需要切入的类: - -```java -public class Student { - String name; - int age; - - //分别在test方法执行前后切入 - public int test(String str) { - System.out.println("我是一个测试方法:"+str); - return str.length(); - } -} -``` - -现在我们希望在`test`方法执行前后添加我们的额外执行的内容,接着,我们来看看如何为方法执行前和执行后添加切入动作。比如现在我们想在方法返回之后,再执行我们的动作,首先定义我们要执行的操作: - -```java -public class AopTest { - - //执行之后的方法 - public void after(){ - System.out.println("我是执行之后"); - } - - //执行之前的方法 - public void before(){ - System.out.println("我是执行之前"); - } -} -``` - -那么,现在如何告诉Spring我们需要在方法执行之前和之后插入其他逻辑呢?首先我们将要进行AOP操作的类注册为Bean: - -```xml - - -``` - -一个是Student类,还有一个就是包含我们要切入方法的AopTest类,注册为Bean后,他们就交给Spring进行管理,这样Spring才能帮助我们完成AOP操作。 - -接着,我们需要告诉Spring,我们需要添加切入点,首先将顶部修改为,引入aop相关标签: - -```xml - - -``` - -通过使用`aop:config`来添加一个新的AOP配置: - -```xml - - - -``` - -首先第一行,我们需要告诉Spring,我们要切入的是哪一个类的哪个或是哪些方法: - -```xml - -``` - -其中,`expression`属性的`execution`填写格式如下: - -```xml -修饰符 包名.类名.方法名称(方法参数) -``` - -* 修饰符:public、protected、private、包括返回值类型、static等等(使用*代表任意修饰符) -* 包名:如com.test(*代表全部,比如com.*代表com包下的全部包) -* 类名:使用*也可以代表包下的所有类 -* 方法名称:可以使用*代表全部方法 -* 方法参数:填写对应的参数即可,比如(String, String),也可以使用*来代表任意一个参数,使用..代表所有参数。 - -也可以使用其他属性来进行匹配,比如`@annotation`可以用于表示标记了哪些注解的方法被切入。 - -接着,我们需要为此方法添加一个执行前动作和一个执行后动作: - -```xml - - - - -``` - -这样,我们就完成了全部的配置,现在来实验一下吧: - -```java -public static void main(String[] args) { - ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("test.xml"); - Student student = context.getBean(Student.class); - student.test("lbwnb"); -} -``` - -我们发现,方法执行前后,分别调用了我们对应的方法。但是仅仅这样还是不能满足一些需求,在某些情况下,我们可以需求方法执行的一些参数,比如方法执行之后返回了什么,或是方法开始之前传入了什么参数等等。 - -这个时候,我们可以为我们切入的方法添加一个参数,通过此参数就可以快速获取切点位置的一些信息: - -```java -//执行之前的方法 -public void before(JoinPoint point){ - System.out.println("我是执行之前"); - System.out.println(point.getTarget()); //获取执行方法的对象 - System.out.println(Arrays.toString(point.getArgs())); //获取传入方法的实参 -} -``` - -通过添加JoinPoint作为形参,Spring会自动给我们一个实现类对象,这样我们就能获取方法的一些信息了。 - -最后我们再来看环绕方法,环绕方法相当于完全代理了此方法,它完全将此方法包含在中间,需要我们手动调用才可以执行此方法,并且我们可以直接获取更多的参数: - -```java -public Object around(ProceedingJoinPoint joinPoint) throws Throwable { - System.out.println("方法开始之前"); - Object value = joinPoint.proceed(); - System.out.println("方法执行完成,结果为:"+value); - return value; -} -``` - -注意,如果代理方法存在返回值,那么环绕方法也需要有一个返回值,通过`proceed`方法来执行代理的方法,也可以修改参数之后调用`proceed(Object[])`,使用我们给定的参数再去执行: - -```java -public Object around(ProceedingJoinPoint joinPoint) throws Throwable { - System.out.println("方法开始之前"); - String arg = joinPoint.getArgs()[0] + "伞兵一号"; - Object value = joinPoint.proceed(new Object[]{arg}); - System.out.println("方法执行完成,结果为:"+value); - return value; -} -``` - -### 使用接口实现AOP - -前面我们介绍了如何使用xml配置一个AOP操作,这节课我们来看看如何使用Advice实现AOP。 - -它与我们之前学习的动态代理更接近一些,比如在方法开始执行之前或是执行之后会去调用我们实现的接口,首先我们需要将一个类实现Advice接口,只有实现此接口,才可以被通知,比如我们这里使用`MethodBeforeAdvice`表示是一个在方法执行之前的动作: - -```java -public class AopTest implements MethodBeforeAdvice { - @Override - public void before(Method method, Object[] args, Object target) throws Throwable { - System.out.println("通过Advice实现AOP"); - } -} -``` - -我们发现,方法中包括了很多的参数,其中args代表的是方法执行前得到的实参列表,还有target表示执行此方法的实例对象。运行之后,效果和之前是一样的,但是在这里我们就可以快速获取到更多信息。 - -```xml - - - - -``` - -除了此接口以外,还有其他的接口,比如`AfterReturningAdvice`就需要实现一个方法执行之后的操作: - -```java -public class AopTest implements MethodBeforeAdvice, AfterReturningAdvice { - @Override - public void before(Method method, Object[] args, Object target) throws Throwable { - System.out.println("我是方法执行之前!"); - } - - @Override - public void afterReturning(Object returnValue, Method method, Object[] args, Object target) throws Throwable { - System.out.println("我是方法执行之后!"); - } -} -``` - -其实,我们之前学习的操作正好对应了AOP 领域中的特性术语: - -- 通知(Advice): AOP 框架中的增强处理,通知描述了切面何时执行以及如何执行增强处理,也就是我们上面编写的方法实现。 -- 连接点(join point): 连接点表示应用执行过程中能够插入切面的一个点,这个点可以是方法的调用、异常的抛出,实际上就是我们在方法执行前或是执行后需要做的内容。 -- 切点(PointCut): 可以插入增强处理的连接点,可以是方法执行之前也可以方法执行之后,还可以是抛出异常之类的。 -- 切面(Aspect): 切面是通知和切点的结合,我们之前在xml中定义的就是切面,包括很多信息。 -- 引入(Introduction):引入允许我们向现有的类添加新的方法或者属性。 -- 织入(Weaving): 将增强处理添加到目标对象中,并创建一个被增强的对象,我们之前都是在将我们的增强处理添加到目标对象,也就是织入(这名字挺有文艺范的) - -*** - -## 使用注解开发 - -前面我们已经了解了IoC容器和AOP实现,但是我们发现,要使用这些功能,我们就不得不编写大量的配置,这是非常浪费时间和精力的,并且我们还只是演示了几个小的例子,如果是像之前一样去编写一个完整的Web应用程序,那么产生的配置可能会非常多。能否有一种更加高效的方法能够省去配置呢?当然还是注解了。 - -所以说,第一步先把你的xml配置文件删了吧,现在我们全部使用注解进行开发(哈哈,是不是感觉XML配置白学了) - -### 注解实现配置文件 - -那么,现在既然不使用XML文件了,那通过注解的方式就只能以实体类的形式进行配置了,我们在要作为配置的类上添加`@Configuration`注解,我们这里创建一个新的类`MainConfiguration`: - -```java -@Configuration -public class MainConfiguration { - //没有配置任何Bean -} -``` - -你可以直接把它等价于: - -```xml - - - - -``` - -那么我们来看看,如何配置Bean,之前我们是直接在配置文件中编写Bean的一些信息,现在在配置类中,我们只需要编写一个方法,并返回我们要创建的Bean的对象即可,并在其上方添加`@Bean`注解: - -```java -@Bean -public Card card(){ - return new Card(); -} -``` - -这样,等价于: - -```xml - - - - -``` - -我们还可以继续添加`@Scope`注解来指定作用域,这里我们就用原型模式: - -```java -@Bean -@Scope("prototype") -public Card card(){ - return new Card(); -} -``` - -采用这种方式,我们就可以更加方便地控制一个Bean对象的创建过程,现在相当于这个对象时由我们创建好了再交给Spring进行后续处理,我们可以在对象创建时做很多额外的操作,包括一些属性值的配置等。 - -既然现在我们已经创建好了配置类,接着我们就可以在主方法中加载此配置类,并创建一个基于配置类的容器: - -```java -public class Main { - public static void main(String[] args) { - //使用AnnotationConfigApplicationContext来实现注解配置 - AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(MainConfiguration.class); //这里需要告诉Spring哪个类作为配置类 - Card card = context.getBean(Card.class); //容器用法和之前一样 - System.out.println(card); - } -} -``` - -在配置的过程中,我们可以点击IDEA底部的Spring标签,打开后可以对当前向容器中注册的Bean进行集中查看,并且会标注Bean之间的依赖关系,我们可以发现,Bean的默认名称实际上就是首字母小写的方法名称,我们也可以手动指定: - -```java -@Bean("lbwnb") -@Scope("prototype") -public Card card(){ - return new Card(); -} -``` - -除了像原来一样在配置文件中创建Bean以外,我们还可以直接在类上添加`@Component`注解来将一个类进行注册**(现在最常用的方式)**,不过要实现这样的方式,我们需要添加一个自动扫描,来告诉Spring需要在哪些包中查找我们提供`@Component`声明的Bean。 - -只需要在配置类上添加一个`@ComponentScan`注解即可,如果要添加多个包进行扫描,可以使用`@ComponentScans`来批量添加。这里我们演示将`bean`包下的所有类进行扫描: - -```java -@ComponentScan("com.test.bean") -@Configuration -public class MainConfiguration { - -} -``` - -现在删除类中的Bean定义,我们在Student类的上面添加`@Component`注解,来表示此类型需要作为Bean交给容器进行管理: - -```java -@Component -@Scope("prototype") -public class Student { - String name; - int age; - Card card; -} -``` - -同样的,在类上也可以直接添加`@Scope`来限定作用域。 - -效果和刚刚实际上是相同的,我们可以来测试一下: - -```java -public class Main { - public static void main(String[] args) { - AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(MainConfiguration.class); - System.out.println(context.getBean(Student.class)); - } -} -``` - -我们可以看到IDEA的Spring板块中也显示了我们刚刚通过直接在类上添加`@Component`声明的Bean。 - -与`@Component`同样效果的还有`@Controller`、`@Service`和`@Repository`,但是现在暂时不提,讲到SpringMVC时再来探讨。 - -现在我们就有两种方式注册一个Bean了,那么如何实现像之前一样的自动注入呢,比如我们将Card也注册为Bean,我们希望Spring自动将其注入到Student的属性中: - -```java -@Component -public class Student { - String name; - int sid; - Card card; -} -``` - -因此,我们可以将此类型,也通过这种方式注册为一个Bean: - -```java -@Component -@Scope("prototype") -public class Card { -} -``` - -现在,我们在需要注入的位置,添加一个`@Resource`注解来实现自动装配: - -```java -@Component -public class Student { - String name; - int sid; - - @Resource - Card card; -} -``` - -这样的好处是,我们完全不需要创建任何的set方法,只需要添加这样的一个注解就可以了,Spring会跟之前配置文件的自动注入一样,在整个容器中进行查找,并将对应的Bean实例对象注入到此属性中,当然,如果还是需要通过set方法来注入,可以将注解添加到方法上: - -```java -@Component -public class Student { - String name; - int sid; - Card card; - - @Resource - public void setCard(Card card) { - System.out.println("通过方法"); - this.card = card; - } -} -``` - -除了使用`@Resource`以外,我们还可以使用`@Autowired`(IDEA不推荐将其使用在字段上,会出现黄标,但是可以放在方法或是构造方法上),它们的效果是一样的,但是它们存在区别,虽然它们都是自动装配: - -* @Resource默认**ByName**如果找不到则**ByType**,可以添加到set方法、字段上。 -* @Autowired默认是**byType**,可以添加在构造方法、set方法、字段、方法参数上。 - -并且`@Autowired`可以配合`@Qualifier`使用,来指定一个名称的Bean进行注入: - -```java -@Autowired -@Qualifier("sxc") -public void setCard(Card card) { - System.out.println("通过方法"); - this.card = card; -} -``` - -如果Bean是在配置文件中进行定义的,我们还可以在方法的参数中使用`@Autowired`来进行自动注入: - -```java -@ComponentScan("com.test.bean") -@Configuration -public class MainConfiguration { - - @Bean - public Student student(@Autowired Card card){ - Student student = new Student(); - student.setCard(card); - return student; - } -} -``` - -我们还可以通过`@PostConstruct`注解来添加构造后执行的方法,它等价于之前讲解的`init-method`: - -```java -@PostConstruct -public void init(){ - System.out.println("我是初始化方法!1"); -} -``` - -注意它们的顺序:Constructor(构造方法) -> @Autowired(依赖注入) -> @PostConstruct - -同样的,如果需要销毁方法,也可以使用`@PreDestroy`注解,这里就不做演示了。 - -这样,两种通过注解进行Bean声明的方式就讲解完毕了,那么什么时候该用什么方式去声明呢? - -* 如果要注册为Bean的类是由其他框架提供,我们无法修改其源代码,那么我们就使用第一种方式进行配置。 -* 如果要注册为Bean的类是我们自己编写的,我们就可以直接在类上添加注解,并在配置中添加扫描。 - -### 注解实现AOP操作 - -了解了如何使用注解注册Bean之后,我们接着来看如何通过注解实现AOP操作,首先我们需要在主类添加`@EnableAspectJAutoProxy`注解,开启AOP注解支持: - -```java -@EnableAspectJAutoProxy -@ComponentScan("com.test.bean") -@Configuration -public class MainConfiguration { -} -``` - -接着我们只需在定义AOP增强操作的类上添加`@Aspect`注解和`@Component`将其注册为Bean即可,就像我们之前在配置文件中也要将其注册为Bean: - -```java -@Component -@Aspect -public class AopTest { - -} -``` - -接着,我们直接在里面编写方法,并将此方法添加到一个切点中,比如我们希望在Student的test方法执行之前执行我们的方法: - -```java -public int test(String str){ - System.out.println("我被调用了:"+str); - return str.length(); -} -``` - -只需要添加`@Before`注解即可: - -```java -@Before("execution(* com.test.bean.Student.test(..))") -public void before(){ - System.out.println("我是之前执行的内容!"); -} -``` - -同样的,我们可以为其添加`JoinPoint`参数来获取切入点信息: - -```java -@Before("execution(* com.test.bean.Student.test(..))") -public void before(JoinPoint point){ - System.out.println("参数列表:"+ Arrays.toString(point.getArgs())); - System.out.println("我是之前执行的内容!"); -} -``` - -我们也可以使用`@AfterReturning`注解来指定方法返回后的操作: - -```java -@AfterReturning(value = "execution(* com.test.bean.Student.test(..))", returning = "returnVal") -public void after(Object returnVal){ - System.out.println("方法已返回,结果为:"+returnVal); -} -``` - -我们还可以指定returning属性,并将其作为方法某个参数的实参。同样的,环绕也可以直接通过注解声明: - -```java -@Around("execution(* com.test.bean.Student.test(..))") -public Object around(ProceedingJoinPoint point) throws Throwable { - System.out.println("方法执行之前!"); - Object val = point.proceed(); - System.out.println("方法执行之后!"); - return val; -} -``` - -### 其他注解配置 - -配置文件可能不止一个,我们有可能会根据模块划分,定义多个配置文件,这个时候,可能会出现很多个配置类,如果我们需要`@Import`注解来快速将某个类加入到容器中,比如我们现在创建一个新的配置文件,并将数据库Bean也搬过去: - -```java -public class Test2Configuration { - @Bean - public Connection getConnection() throws SQLException { - System.out.println("创建新的连接!"); - return DriverManager.getConnection("jdbc:mysql://localhost:3306/study", - "root", - "root"); - } -} -``` - -```java -@EnableAspectJAutoProxy -@Configuration -@ComponentScan("com.test") -@Import(Test2Configuration.class) -public class TestConfiguration { - - @Resource - Connection connection; - - @PostConstruct - public void init(){ - System.out.println(connection); - } -} -``` - -注意另一个配置类并没有添加任何注解,实际上,相当于导入的类被强制注册为了一个Bean,到现在,我们一共了解了三种注册为Bean的方式,利用这种特性,我们还可以将其他的类型也强制注册为Bean: - -```java -@EnableAspectJAutoProxy -@Configuration -@ComponentScan("com.test") -@Import({Test2Configuration.class, Date.class}) -public class TestConfiguration { - - @Resource - Connection connection; - @Resource - Date date; - - @PostConstruct - public void init(){ - System.out.println(date+" -> "+connection); - } -} -``` - -可以看到,日期直接作为一个Bean放入到IoC容器中了,并且时间永远都是被new的那个时间,也就是同一个对象(因为默认是单例模式)。 - -通过`@Import`方式最主要为了实现的目标并不是创建Bean,而是为了方便一些框架的`Registrar`进行Bean定义,在讲解到Spring原理时,我们再来详细讨论,目前只做了解即可。 - -到这里,关于Spring框架的大致内容就聊得差不多了,其余的内容,我们会在后面继续讲解。 - -*** - -## 深入Mybatis框架 - -学习了Spring之后,我们已经了解如何将一个类作为Bean交由IoC容器管理,也就是说,现在我们可以通过更方便的方式来使用Mybatis框架,我们可以直接把SqlSessionFactory、Mapper交给Spring进行管理,并且可以通过注入的方式快速地使用它们。 - -因此,我们要学习一下如何将Mybatis与Spring进行整合,那么首先,我们需要在之前知识的基础上继续深化学习。 - -### 了解数据源 - -在之前,我们如果需要创建一个JDBC的连接,那么必须使用`DriverManager.getConnection()`来创建连接,连接建立后,我们才可以进行数据库操作。 - -而学习了Mybatis之后,我们就不用再去使用`DriverManager`为我们提供连接对象,而是直接使用Mybatis为我们提供的`SqlSessionFactory`工具类来获取对应的`SqlSession`通过会话对象去操作数据库。 - -那么,它到底是如何封装JDBC的呢?我们可以试着来猜想一下,会不会是Mybatis每次都是帮助我们调用`DriverManager`来实现的数据库连接创建?我们可以看看Mybatis的源码: - -```java -public SqlSession openSession(boolean autoCommit) { - return this.openSessionFromDataSource(this.configuration.getDefaultExecutorType(), (TransactionIsolationLevel)null, autoCommit); -} -``` - -在通过`SqlSessionFactory`调用`openSession`方法之后,它调用了内部的一个私有的方法`openSessionFromDataSource`,我们接着来看,这个方法里面定义了什么内容: - -```java -private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) { - Transaction tx = null; - - DefaultSqlSession var8; - try { - //获取当前环境(由配置文件映射的对象实体) - Environment environment = this.configuration.getEnvironment(); - //事务工厂(暂时不提,下一板块讲解) - TransactionFactory transactionFactory = this.getTransactionFactoryFromEnvironment(environment); - //配置文件中: - //生成事务(根据我们的配置,会默认生成JdbcTransaction),这里是关键,我们看到这里用到了environment.getDataSource()方法 - tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit); - //执行器,包括全部的数据库操作方法定义,本质上是在使用执行器操作数据库,需要传入事务对象 - Executor executor = this.configuration.newExecutor(tx, execType); - //封装为SqlSession对象 - var8 = new DefaultSqlSession(this.configuration, executor, autoCommit); - } catch (Exception var12) { - this.closeTransaction(tx); - throw ExceptionFactory.wrapException("Error opening session. Cause: " + var12, var12); - } finally { - ErrorContext.instance().reset(); - } - - return var8; -} -``` - -也就是说,我们的数据源配置信息,存放在了`Transaction`对象中,那么现在我们只需要知道执行器到底是如何执行SQL语句的,我们就知道到底如何创建`Connection`对象了,就需要获取数据库的链接信息了,那么我们来看看,这个`DataSource`到底是个什么: - -```java -public interface DataSource extends CommonDataSource, Wrapper { - - Connection getConnection() throws SQLException; - - Connection getConnection(String username, String password) - throws SQLException; -} -``` - -我们发现,它是在`javax.sql`定义的一个接口,它包括了两个方法,都是用于获取连接的。因此,现在我们可以断定,并不是通过之前`DriverManager`的方法去获取连接了,而是使用`DataSource`的实现类来获取的,因此,也就正式引入到我们这一节的话题了: - -> 数据库链接的建立和关闭是极其耗费系统资源的操作,通过DriverManager获取的数据库连接,一个数据库连接对象均对应一个物理数据库连接,每次操作都打开一个物理连接,使用完后立即关闭连接,频繁的打开、关闭连接会持续消耗网络资源,造成整个系统性能的低下。 - -因此,JDBC为我们定义了一个数据源的标准,也就是`DataSource`接口,告诉数据源数据库的连接信息,并将所有的连接全部交给数据源进行集中管理,当需要一个`Connection`对象时,可以向数据源申请,数据源会根据内部机制,合理地分配连接对象给我们。 - -一般比较常用的`DataSource`实现,都是采用池化技术,就是在一开始就创建好N个连接,这样之后使用就无需再次进行连接,而是直接使用现成的`Connection`对象进行数据库操作。 - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Faliyunzixunbucket.oss-cn-beijing.aliyuncs.com%2Fjpg%2Fe9e0ccfda1fe04136d0388f8539ea402.jpg%3Fx-oss-process%3Dimage%2Fresize%2Cp_100%2Fauto-orient%2C1%2Fquality%2Cq_90%2Fformat%2Cjpg%2Fwatermark%2Cimage_eXVuY2VzaGk%3D%2Ct_100&refer=http%3A%2F%2Faliyunzixunbucket.oss-cn-beijing.aliyuncs.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1641441442&t=c6b5a2eec0786a21357f91126e8c8335) - -当然,也可以使用传统的即用即连的方式获取`Connection`对象,Mybatis为我们提供了几个默认的数据源实现,我们之前一直在使用的是官方的默认配置,也就是池化数据源: - -```xml - - - - - - -``` - -一共三个选项: - -* UNPOOLED 不使用连接池的数据源 -* POOLED 使用连接池的数据源 -* JNDI 使用JNDI实现的数据源 - -### 解读Mybatis数据源实现 - -那么我们先来看看,不使用池化的数据源实现,它叫做`UnpooledDataSource`,我们来看看源码: - -```java -public class UnpooledDataSource implements DataSource { - private ClassLoader driverClassLoader; - private Properties driverProperties; - private static Map registeredDrivers = new ConcurrentHashMap(); - private String driver; - private String url; - private String username; - private String password; - private Boolean autoCommit; - private Integer defaultTransactionIsolationLevel; - private Integer defaultNetworkTimeout; -``` - -首先这个类中定义了很多的成员,包括数据库的连接信息、数据库驱动信息、事务相关信息等。 - -我们接着来看,它是如何实现`DataSource`中提供的接口的: - -```java -public Connection getConnection() throws SQLException { - return this.doGetConnection(this.username, this.password); -} - -public Connection getConnection(String username, String password) throws SQLException { - return this.doGetConnection(username, password); -} -``` - -实际上,这两个方法都指向了内部的一个`doGetConnection`方法,那么我们接着来看: - -```java -private Connection doGetConnection(String username, String password) throws SQLException { - Properties props = new Properties(); - if (this.driverProperties != null) { - props.putAll(this.driverProperties); - } - - if (username != null) { - props.setProperty("user", username); - } - - if (password != null) { - props.setProperty("password", password); - } - - return this.doGetConnection(props); -} -``` - -首先它将数据库的连接信息也给添加到`Properties`对象中进行存放,并交给下一个`doGetConnection`来处理,套娃就完事了呗,接着来看下一层源码: - -```java -private Connection doGetConnection(Properties properties) throws SQLException { - //若未初始化驱动,需要先初始化,内部维护了一个Map来记录初始化信息,这里不多介绍了 - this.initializeDriver(); - //传统的获取连接的方式 - Connection connection = DriverManager.getConnection(this.url, properties); - //对连接进行额外的一些配置 - this.configureConnection(connection); - return connection; -} -``` - -到这里,就返回`Connection`对象了,而此对象正是通过`DriverManager`来创建的,因此,非池化的数据源实现依然使用的是传统的连接创建方式,那我们接着来看池化的数据源实现,它是`PooledDataSource`类: - -```java -public class PooledDataSource implements DataSource { - private static final Log log = LogFactory.getLog(PooledDataSource.class); - private final PoolState state = new PoolState(this); - private final UnpooledDataSource dataSource; - protected int poolMaximumActiveConnections = 10; - protected int poolMaximumIdleConnections = 5; - protected int poolMaximumCheckoutTime = 20000; - protected int poolTimeToWait = 20000; - protected int poolMaximumLocalBadConnectionTolerance = 3; - protected String poolPingQuery = "NO PING QUERY SET"; - protected boolean poolPingEnabled; - protected int poolPingConnectionsNotUsedFor; - private int expectedConnectionTypeCode; -``` - -我们发现,在这里的定义就比非池化的实现复杂得多了,因为它还要考虑并发的问题,并且还要考虑如何合理地存放大量的链接对象,该如何进行合理分配,因此它的玩法非常之高级,但是,再高级的玩法,我们都要拿下。 - -首先注意,它存放了一个UnpooledDataSource,此对象是在构造时就被创建,其实创建Connection还是依靠数据库驱动创建,我们后面慢慢解析,首先我们来看看它是如何实现接口方法的: - -```java -public Connection getConnection() throws SQLException { - return this.popConnection(this.dataSource.getUsername(), this.dataSource.getPassword()).getProxyConnection(); -} - -public Connection getConnection(String username, String password) throws SQLException { - return this.popConnection(username, password).getProxyConnection(); -} -``` - -可以看到,它调用了`popConnection()`方法来获取连接对象,然后进行了一个代理,我们可以猜测,有可能整个连接池就是一个类似于栈的集合类型结构实现的。那么我们接着来看看`popConnection`方法: - -```java -private PooledConnection popConnection(String username, String password) throws SQLException { - boolean countedWait = false; - //返回的是PooledConnection对象, - PooledConnection conn = null; - long t = System.currentTimeMillis(); - int localBadConnectionCount = 0; - - while(conn == null) { - synchronized(this.state) { //加锁,因为有可能很多个线程都需要获取连接对象 - PoolState var10000; - //PoolState存了两个List,一个是空闲列表,一个是活跃列表 - if (!this.state.idleConnections.isEmpty()) { //有空闲连接时,可以直接分配Connection - conn = (PooledConnection)this.state.idleConnections.remove(0); //ArrayList中取第一个元素 - if (log.isDebugEnabled()) { - log.debug("Checked out connection " + conn.getRealHashCode() + " from pool."); - } - //如果已经没有多余的连接可以分配,那么就检查一下活跃连接数是否达到最大的分配上限,如果没有,就new一个 - } else if (this.state.activeConnections.size() < this.poolMaximumActiveConnections) { - //注意new了之后并没有立即往List里面塞,只是存了一些基本信息 - //我们发现,这里依靠UnpooledDataSource创建了一个Connection对象,并将其封装到PooledConnection中 - conn = new PooledConnection(this.dataSource.getConnection(), this); - if (log.isDebugEnabled()) { - log.debug("Created connection " + conn.getRealHashCode() + "."); - } - //以上条件都不满足,那么只能从之前的连接中寻找了,看看有没有那种卡住的链接(由于网络问题有可能之前的连接一直被卡住,然而正常情况下早就结束并且可以使用了,所以这里相当于是优化也算是一种捡漏的方式) - } else { - //获取最早创建的连接 - PooledConnection oldestActiveConnection = (PooledConnection)this.state.activeConnections.get(0); - long longestCheckoutTime = oldestActiveConnection.getCheckoutTime(); - //判断是否超过最大的使用时间 - if (longestCheckoutTime > (long)this.poolMaximumCheckoutTime) { - //超时统计信息(不重要) - ++this.state.claimedOverdueConnectionCount; - var10000 = this.state; - var10000.accumulatedCheckoutTimeOfOverdueConnections += longestCheckoutTime; - var10000 = this.state; - var10000.accumulatedCheckoutTime += longestCheckoutTime; - //从活跃列表中移除此链接信息 - this.state.activeConnections.remove(oldestActiveConnection); - //如果开启事务,还需要回滚一下 - if (!oldestActiveConnection.getRealConnection().getAutoCommit()) { - try { - oldestActiveConnection.getRealConnection().rollback(); - } catch (SQLException var15) { - log.debug("Bad connection. Could not roll back"); - } - } - - //这里就根据之前的连接对象直接new一个新的连接(注意使用的还是之前的Connection对象,只是被重新封装了) - conn = new PooledConnection(oldestActiveConnection.getRealConnection(), this); - conn.setCreatedTimestamp(oldestActiveConnection.getCreatedTimestamp()); - conn.setLastUsedTimestamp(oldestActiveConnection.getLastUsedTimestamp()); - //过期 - oldestActiveConnection.invalidate(); - if (log.isDebugEnabled()) { - log.debug("Claimed overdue connection " + conn.getRealHashCode() + "."); - } - } else { - //确实是没得用了,只能卡住了(阻塞) - //然后记录一下有几个线程在等待当前的任务搞完 - try { - if (!countedWait) { - ++this.state.hadToWaitCount; - countedWait = true; - } - - if (log.isDebugEnabled()) { - log.debug("Waiting as long as " + this.poolTimeToWait + " milliseconds for connection."); - } - - long wt = System.currentTimeMillis(); - this.state.wait((long)this.poolTimeToWait); //要是超过等待时间还是没等到,只能放弃 - //注意这样的话con就为null了 - var10000 = this.state; - var10000.accumulatedWaitTime += System.currentTimeMillis() - wt; - } catch (InterruptedException var16) { - break; - } - } - } - - //经过之前的操作,已经成功分配到连接对象的情况下 - if (conn != null) { - if (conn.isValid()) { //是否有效 - if (!conn.getRealConnection().getAutoCommit()) { //清理之前遗留的事务操作 - conn.getRealConnection().rollback(); - } - - conn.setConnectionTypeCode(this.assembleConnectionTypeCode(this.dataSource.getUrl(), username, password)); - conn.setCheckoutTimestamp(System.currentTimeMillis()); - conn.setLastUsedTimestamp(System.currentTimeMillis()); - //添加到活跃表中 - this.state.activeConnections.add(conn); - //统计信息(不重要) - ++this.state.requestCount; - var10000 = this.state; - var10000.accumulatedRequestTime += System.currentTimeMillis() - t; - } else { - //无效的连接,直接抛异常 - if (log.isDebugEnabled()) { - log.debug("A bad connection (" + conn.getRealHashCode() + ") was returned from the pool, getting another connection."); - } - - ++this.state.badConnectionCount; - ++localBadConnectionCount; - conn = null; - if (localBadConnectionCount > this.poolMaximumIdleConnections + this.poolMaximumLocalBadConnectionTolerance) { - if (log.isDebugEnabled()) { - log.debug("PooledDataSource: Could not get a good connection to the database."); - } - - throw new SQLException("PooledDataSource: Could not get a good connection to the database."); - } - } - } - } - } - - //最后该干嘛干嘛,拿不到连接直接抛异常 - if (conn == null) { - if (log.isDebugEnabled()) { - log.debug("PooledDataSource: Unknown severe error condition. The connection pool returned a null connection."); - } - - throw new SQLException("PooledDataSource: Unknown severe error condition. The connection pool returned a null connection."); - } else { - return conn; - } -} -``` - -经过上面一顿猛如虎的操作之后,我们可以得到以下信息: - -> 如果最后得到了连接对象(有可能是从空闲列表中得到,有可能是直接创建的新的,还有可能是经过回收策略回收得到的),那么连接(Connection)对象一定会被放在活跃列表中(state.activeConnections) - -那么肯定有一个疑问,现在我们已经知道获取一个链接会直接进入到活跃列表中,那么,如果一个连接被关闭,又会发生什么事情呢,我们来看看此方法返回之后,会调用`getProxyConnection`来获取一个代理对象,实际上就是`PooledConnection`类: - -```java -class PooledConnection implements InvocationHandler { - private static final String CLOSE = "close"; - private static final Class[] IFACES = new Class[]{Connection.class}; - private final int hashCode; - //会记录是来自哪一个数据源创建的的 - private final PooledDataSource dataSource; - //连接对象本体 - private final Connection realConnection; - //代理的链接对象 - private final Connection proxyConnection; - ... -``` - -它直接代理了构造方法中传入的Connection对象,也是使用JDK的动态代理实现的,那么我们来看一下,它是如何进行代理的: - -```java -public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { - String methodName = method.getName(); - //如果调用的是Connection对象的close方法, - if ("close".equals(methodName)) { - //这里并不会真的关闭连接(这也是为什么用代理),而是调用之前数据源的pushConnection方法,将此连接改为为空闲状态 - this.dataSource.pushConnection(this); - return null; - } else { - try { - if (!Object.class.equals(method.getDeclaringClass())) { - this.checkConnection(); - //任何操作执行之前都会检查连接是否可用 - } - - //该干嘛干嘛 - return method.invoke(this.realConnection, args); - } catch (Throwable var6) { - throw ExceptionUtil.unwrapThrowable(var6); - } - } -} -``` - -那么我们最后再来看看`pushConnection`方法: - -```java -protected void pushConnection(PooledConnection conn) throws SQLException { - synchronized(this.state) { //老规矩,先来把锁 - //先从活跃列表移除此连接 - this.state.activeConnections.remove(conn); - //判断此链接是否可用 - if (conn.isValid()) { - PoolState var10000; - //看看闲置列表容量是否已满(容量满了就回不去了) - if (this.state.idleConnections.size() < this.poolMaximumIdleConnections && conn.getConnectionTypeCode() == this.expectedConnectionTypeCode) { - var10000 = this.state; - var10000.accumulatedCheckoutTime += conn.getCheckoutTime(); - if (!conn.getRealConnection().getAutoCommit()) { - conn.getRealConnection().rollback(); - } - - //把唯一有用的Connection对象拿出来,然后重新创建一个PooledConnection - PooledConnection newConn = new PooledConnection(conn.getRealConnection(), this); - //放入闲置列表,成功回收 - this.state.idleConnections.add(newConn); - newConn.setCreatedTimestamp(conn.getCreatedTimestamp()); - newConn.setLastUsedTimestamp(conn.getLastUsedTimestamp()); - conn.invalidate(); - if (log.isDebugEnabled()) { - log.debug("Returned connection " + newConn.getRealHashCode() + " to pool."); - } - - this.state.notifyAll(); - } else { - var10000 = this.state; - var10000.accumulatedCheckoutTime += conn.getCheckoutTime(); - if (!conn.getRealConnection().getAutoCommit()) { - conn.getRealConnection().rollback(); - } - - conn.getRealConnection().close(); - if (log.isDebugEnabled()) { - log.debug("Closed connection " + conn.getRealHashCode() + "."); - } - - conn.invalidate(); - } - } else { - if (log.isDebugEnabled()) { - log.debug("A bad connection (" + conn.getRealHashCode() + ") attempted to return to the pool, discarding connection."); - } - - ++this.state.badConnectionCount; - } - - } -} -``` - -这样,我们就已经完全了解了Mybatis的池化数据源的执行流程了。只不过,无论Connection管理方式如何变换,无论数据源再高级,我们要知道,它都最终都会使用`DriverManager`来创建连接对象,而最终使用的也是`DriverManager`提供的`Connection`对象。 - -### 整合Mybatis框架 - -通过了解数据源,我们已经清楚,Mybatis实际上是在使用自己编写的数据源(数据源有很多,之后我们再聊其他的)默认使用的是池化的数据源,它预先存储了很多的连接对象。 - -那么我们来看一下,如何将Mybatis与Spring更好的结合呢,比如我们现在希望将SqlSessionFactory交给IoC容器进行管理,而不是我们自己创建工具类来管理(我们之前一直都在使用工具类管理和创建会话) - -首先导入依赖: - -```xml - - mysql - mysql-connector-java - 8.0.25 - - - org.mybatis - mybatis - 3.5.7 - - - org.mybatis - mybatis-spring - 2.0.6 - - - org.springframework - spring-jdbc - 5.3.13 - -``` - -在mybatis-spring依赖中,为我们提供了SqlSessionTemplate类,它其实就是官方封装的一个工具类,我们可以将其注册为Bean,这样我们随时都可以向IoC容器索要,而不用自己再去编写一个工具类了,我们可以直接在配置类中创建: - -```java -@Configuration -@ComponentScan("com.test") -public class TestConfiguration { - @Bean - public SqlSessionTemplate sqlSessionTemplate() throws IOException { - SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsReader("mybatis-config.xml")); - return new SqlSessionTemplate(factory); - } -} -``` - -```xml - - - - - - - - - - - - - - - - - - -``` - -```java -public static void main(String[] args) { - ApplicationContext context = new AnnotationConfigApplicationContext(TestConfiguration.class); - SqlSessionTemplate template = context.getBean(SqlSessionTemplate.class); - TestMapper testMapper = template.getMapper(TestMapper.class); - System.out.println(testMapper.getStudent()); -} -``` - -```java -@Mapper -public interface TestMapper { - - @Select("select * from student where sid = 1") - Student getStudent(); -} -``` - -```java -@Data -public class Student { - int sid; - String name; - String sex; -} -``` - -最后成功得到Student实体类,证明`SqlSessionTemplate`成功注册为Bean可以使用了。虽然这样已经很方便了,但是还不够方便,我们依然需要手动去获取Mapper对象,那么能否直接得到对应的Mapper对象呢,我们希望让Spring直接帮助我们管理所有的Mapper,当需要时,可以直接从容器中获取,我们可以直接在配置类上方添加注解: - -```java -@MapperScan("com.test.mapper") -``` - -这样,Spring会自动扫描所有的Mapper,并将其实现注册为Bean,那么我们现在就可以直接通过容器获取了: - -```java -public static void main(String[] args) throws InterruptedException { - ApplicationContext context = new AnnotationConfigApplicationContext(TestConfiguration.class); - TestMapper mapper = context.getBean(TestMapper.class); - System.out.println(mapper.getStudent()); -} -``` - -请一定注意,必须存在`SqlSessionTemplate`或是`SqlSessionFactoryBean`的Bean,否则会无法初始化(毕竟要数据库的链接信息) - -我们接着来看,如果我们希望直接去除Mybatis的配置文件,那么改怎么去实现呢?我们可以使用`SqlSessionFactoryBean`类: - -```java -@Configuration -@ComponentScan("com.test") -@MapperScan("com.test.mapper") -public class TestConfiguration { - @Bean - public DataSource dataSource(){ - return new PooledDataSource("com.mysql.cj.jdbc.Driver", - "jdbc:mysql://localhost:3306/study", "root", "123456"); - } - - @Bean - public SqlSessionFactoryBean sqlSessionFactoryBean(@Autowired DataSource dataSource){ - SqlSessionFactoryBean bean = new SqlSessionFactoryBean(); - bean.setDataSource(dataSource); - return bean; - } -} -``` - -首先我们需要创建一个数据源的实现类,因为这是数据库最基本的信息,然后再给到`SqlSessionFactoryBean`实例,这样,我们相当于直接在一开始通过IoC容器配置了`SqlSessionFactory`,只需要传入一个`DataSource`的实现即可。 - -删除配置文件,重新再来运行,同样可以正常使用Mapper。从这里开始,通过IoC容器,Mybatis已经不再需要使用配置文件了,之后基于Spring的开发将不会再出现Mybatis的配置文件。 - -### 使用HikariCP连接池 - -前面我们提到了数据源还有其他实现,比如C3P0、Druid等,它们都是非常优秀的数据源实现(可以自行了解),不过我们这里要介绍的,是之后在SpringBoot中还会遇到的HikariCP连接池。 - -> HikariCP是由日本程序员开源的一个数据库连接池组件,代码非常轻量,并且速度非常的快。根据官方提供的数据,在酷睿i7开启32个线程32个连接的情况下,进行随机数据库读写操作,HikariCP的速度是现在常用的C3P0数据库连接池的数百倍。在SpringBoot2.0中,官方也是推荐使用HikariCP。 - -![img](https://upload-images.jianshu.io/upload_images/2532461-ffcae57755776052.png?imageMogr2/auto-orient/strip|imageView2/2/w/864) - -首先,我们需要导入依赖: - -```xml - - com.zaxxer - HikariCP - 3.4.5 - -``` - -接着修改一下Bean的定义: - -```java -@Bean -public DataSource dataSource() throws SQLException { - HikariDataSource dataSource = new HikariDataSource(); - dataSource.setJdbcUrl("jdbc:mysql://localhost:3306/study"); - dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver"); - dataSource.setUsername("root"); - dataSource.setPassword("123456"); - return dataSource; -} -``` - -最后我们发现,同样可以得到输出结果,但是出现了一个报错: - -```xml -SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder". -SLF4J: Defaulting to no-operation (NOP) logger implementation -SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details. -``` - -此数据源实际上是采用了SLF4J日志框架打印日志信息,但是现在没有任何的日志实现(slf4j只是一个API标准,它规范了多种日志框架的操作,统一使用SLF4J定义的方法来操作不同的日志框架)我们这里就使用JUL作为日志实现,我们需要导入另一个依赖: - -```xml - - org.slf4j - slf4j-jdk14 - 1.7.25 - -``` - -注意版本一定要和`slf4j-api`保持一致! - -这样就能得到我们的日志信息了: - -```xml -十二月 07, 2021 8:46:41 下午 com.zaxxer.hikari.HikariDataSource getConnection -信息: HikariPool-1 - Starting... -十二月 07, 2021 8:46:41 下午 com.zaxxer.hikari.HikariDataSource getConnection -信息: HikariPool-1 - Start completed. -Student(sid=1, name=小明, sex=男) -``` - -在SpringBoot阶段,我们还会遇到`HikariPool-1 - Starting...`和`HikariPool-1 - Start completed.`同款日志信息。 - -当然,Lombok肯定也是支持这个日志框架快速注解的: - -```java -@Slf4j -public class Main { - public static void main(String[] args) { - log.info("项目正在启动..."); - ApplicationContext context = new AnnotationConfigApplicationContext(TestConfiguration.class); - TestMapper mapper = context.getBean(TestMapper.class); - System.out.println(mapper.getStudent()); - } -} -``` - -### Mybatis事务管理 - -我们前面已经讲解了如何让Mybatis与Spring更好地融合在一起,通过将对应的Bean类型注册到容器中,就能更加方便的去使用Mapper,那么现在,我们接着来看Spring的事务控制。 - -在开始之前,我们还是回顾一下事务机制。首先事务遵循一个ACID原则: - -- 原子性(Atomicity):事务是一个原子操作,由一系列动作组成。事务的原子性确保动作要么全部完成,要么完全不起作用。 -- 一致性(Consistency):一旦事务完成(不管成功还是失败),系统必须确保它所建模的业务处于一致的状态,而不会是部分完成部分失败。在现实中的数据不应该被破坏。 -- 隔离性(Isolation):可能有许多事务会同时处理相同的数据,因此每个事务都应该与其他事务隔离开来,防止数据损坏。 -- 持久性(Durability):一旦事务完成,无论发生什么系统错误,它的结果都不应该受到影响,这样就能从任何系统崩溃中恢复过来。通常情况下,事务的结果被写到持久化存储器中。 - -简单来说,事务就是要么完成,要么就啥都别做!并且不同的事务直接相互隔离,互不干扰。 - -那么我们接着来深入了解一下事务的**隔离机制**(在之前数据库入门阶段并没有提到)我们说了,事务之间是相互隔离互不干扰的,那么如果出现了下面的情况,会怎么样呢: - -> 当两个事务同时在执行,并且同时在操作同一个数据,这样很容易出现并发相关的问题,比如一个事务先读取了某条数据,而另一个事务此时修改了此数据,当前一个事务紧接着再次读取时,会导致和前一次读取的数据不一致,这就是一种典型的数据虚读现象。 - -因此,为了解决这些问题,事务之间实际上是存在一些隔离级别的: - -* ISOLATION_READ_UNCOMMITTED(读未提交):其他事务会读取当前事务尚未更改的提交(相当于读取的是这个事务暂时缓存的内容,并不是数据库中的内容) -* ISOLATION_READ_COMMITTED(读已提交):其他事务会读取当前事务已经提交的数据(也就是直接读取数据库中已经发生更改的内容) -* ISOLATION_REPEATABLE_READ(可重复读):其他事务会读取当前事务已经提交的数据并且其他事务执行过程中不允许再进行数据修改(注意这里仅仅是不允许修改数据) -* ISOLATION_SERIALIZABLE(串行化):它完全服从ACID原则,一个事务必须等待其他事务结束之后才能开始执行,相当于挨个执行,效率很低 - -我们依次来看看,不同的隔离级别会导致什么问题。首先是`读未提交`级别,此级别属于最低级别,相当于各个事务共享一个缓存区域,任何事务的操作都在这里进行。那么它会导致以下问题: - -![技术图片](http://image.mamicode.com/info/202004/20200406202855520730.png) - -也就是说,事务A最后得到的实际上是一个毫无意义的数据(事务B已经回滚了)我们称此数据为"脏数据",这种现象称为**脏读** - -我们接着来看`读已提交`级别,事务只能读取其他事务已经提交的内容,相当于直接从数据中读取数据,这样就可以避免**脏读**问题了,但是它还是存在以下问题: - -![技术图片](http://image.mamicode.com/info/202004/20200406202856166262.png) - -这正是我们前面例子中提到的问题,虽然它避免了脏读问题,但是如果事件B修改并提交了数据,那么实际上事务A之前读取到的数据依然不是最新的数据,直接导致两次读取的数据不一致,这种现象称为**虚读**也可以称为**不可重复读** - -因此,下一个隔离级别`可重复读`就能够解决这样的问题(MySQL的默认隔离级别),它规定在其他事务执行时,不允许修改数据,这样,就可以有效地避免不可重复读的问题,但是这样就一定安全了吗?这里仅仅是禁止了事务执行过程中的UPDATE操作,但是它并没有禁止INSERT这类操作,因此,如果事务A执行过程中事务B插入了新的数据,那么A这时是毫不知情的,比如: - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg-blog.csdnimg.cn%2Fimg_convert%2Fb1036ed06cc158d79a17a4e1bbb11de4.png&refer=http%3A%2F%2Fimg-blog.csdnimg.cn&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1641478553&t=2218729b0125730a7874f1cd6193db22) - -两个人同时报名一个活动,两个报名的事务同时在进行,但是他们一开始读取到的人数都是5,而这时,它们都会认为报名成功后人数应该变成6,而正常情况下应该是7,因此这个时候就发生了数据的**幻读**现象。 - -因此,要解决这种问题,只能使用最后一种隔离级别`串行化`来实现了,每个事务不能同时进行,直接避免所有并发问题,简单粗暴,但是效率爆减,并不推荐。 - -最后总结三种情况: - -* 脏读:读取到了被回滚的数据,它毫无意义。 -* 虚读(不可重复读):由于其他事务更新数据,两次读取的数据不一致。 -* 幻读:由于其他事务执行插入删除操作,而又无法感知到表中记录条数发生变化,当下次再读取时会莫名其妙多出或缺失数据,就像产生幻觉一样。 - -(对于虚读和幻读的区分:虚读是某个数据前后读取不一致,幻读是整个表的记录数量前后读取不一致) - -最后这张图,请务必记在你的脑海,记在你的心中,记在你的全世界: - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg.ouyym.com%2Fcontent%2Fa5a7d2e28973a6a1d8bcfd753c218736.png&refer=http%3A%2F%2Fimg.ouyym.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1641479475&t=5f255460ad285932a74ccad3547adcff) - -Mybatis对于数据库的事务管理,也有着相应的封装。一个事务无非就是创建、提交、回滚、关闭,因此这些操作被Mybatis抽象为一个接口: - -```java -public interface Transaction { - Connection getConnection() throws SQLException; - - void commit() throws SQLException; - - void rollback() throws SQLException; - - void close() throws SQLException; - - Integer getTimeout() throws SQLException; -} -``` - -对于此接口的实现,MyBatis的事务管理分为两种形式: - -1. 使用**JDBC**的事务管理机制:即利用对应数据库的驱动生成的`Connection`对象完成对事务的提交(commit())、回滚(rollback())、关闭(close())等,对应的实现类为`JdbcTransaction` -2. 使用**MANAGED**的事务管理机制:这种机制MyBatis自身不会去实现事务管理,而是让程序的容器(比如Spring)来实现对事务的管理,对应的实现类为`ManagedTransaction` - -而我们之前一直使用的其实就是JDBC的事务,相当于直接使用`Connection`对象(之前JavaWeb阶段已经讲解过了)在进行事务操作,并没有额外的管理机制,对应的配置为: - -```xml - -``` - -那么我们来看看`JdbcTransaction`是不是像我们上面所说的那样管理事务的,直接上源码: - -```java -public class JdbcTransaction implements Transaction { - private static final Log log = LogFactory.getLog(JdbcTransaction.class); - protected Connection connection; - protected DataSource dataSource; - protected TransactionIsolationLevel level; - protected boolean autoCommit; - - public JdbcTransaction(DataSource ds, TransactionIsolationLevel desiredLevel, boolean desiredAutoCommit) { - //数据源 - this.dataSource = ds; - //事务隔离级别,上面已经提到过了 - this.level = desiredLevel; - //是否自动提交 - this.autoCommit = desiredAutoCommit; - } - - //也可以直接给个Connection对象 - public JdbcTransaction(Connection connection) { - this.connection = connection; - } - - public Connection getConnection() throws SQLException { - //没有就通过数据源新开一个Connection - if (this.connection == null) { - this.openConnection(); - } - - return this.connection; - } - - public void commit() throws SQLException { - //连接已经创建并且没开启自动提交才可以使用 - if (this.connection != null && !this.connection.getAutoCommit()) { - if (log.isDebugEnabled()) { - log.debug("Committing JDBC Connection [" + this.connection + "]"); - } - //实际上使用的是数据库驱动提供的Connection对象进行事务操作 - this.connection.commit(); - } - - } -``` - -相当于`JdbcTransaction`只是为数据库驱动提供的`Connection`对象套了层壳,所有的事务操作实际上是直接调用`Connection`对象。 - -那么我们接着来看`ManagedTransaction`的源码: - -```java -public class ManagedTransaction implements Transaction { - ... - - public void commit() throws SQLException { - } - - public void rollback() throws SQLException { - } - - ... -} -``` - -我们发现,大体内容和`JdbcTransaction`差不多,但是它并没有实现任何的事务操作。也就是说,它希望将实现交给其他的管理框架来完成,而Spring就为Mybatis提供了一个非常好的事务管理实现。 - -### 使用Spring事务管理 - -现在我们来学习一下Spring提供的事务管理(Spring事务管理分为编程式事务和声明式事务,但是编程式事务过于复杂并且具有高度耦合性,违背了Spring框架的设计初衷,因此这里只讲解声明式事务)声明式事务是基于AOP实现的。 - -使用声明式事务非常简单,我们只需要在配置类添加`@EnableTransactionManagement`注解即可,这样就可以开启Spring的事务支持了。接着,我们只需要把一个事务要做的所有事情封装到Service层的一个方法中即可,首先需要在配置文件中注册一个新的Bean,事务需要执行必须有一个事务管理器: - -```java -@Bean -public TransactionManager transactionManager(@Autowired DataSource dataSource){ - return new DataSourceTransactionManager(dataSource); -} -``` - -接着编写Mapper操作: - -```java -@Mapper -public interface TestMapper { - - @Insert("insert into student(name, sex) values('测试', '男')") - void insertStudent(); -} -``` - -这样会向数据库中插入一条新的学生信息,接着,假设我们这里有一个业务需要连续插入两条学生信息,首先编写业务层的接口: - -```java -public interface TestService { - - void test(); -} -``` - -接着,我们再来编写业务层的实现,我们可以直接将其注册为Bean,交给Spring来进行管理,这样就可以自动将Mapper注入到类中了,并且可以支持事务: - -```java -@Component -public class TestServiceImpl implements TestService{ - - @Resource - TestMapper mapper; - - @Transactional - @Override - public void test() { - mapper.insertStudent(); - if(true) throw new RuntimeException("我是测试异常!"); - mapper.insertStudent(); - } -} -``` - -我们只需在方法上添加`@Transactional`注解,即可表示此方法执行的是一个事务操作,在调用此方法时,Spring会通过AOP机制为其进行增强,一旦发现异常,事务会自动回滚。最后我们来调用一下此方法: - -```java -@Slf4j -public class Main { - public static void main(String[] args) { - log.info("项目正在启动..."); - ApplicationContext context = new AnnotationConfigApplicationContext(TestConfiguration.class); - TestService service = context.getBean(TestService.class); - service.test(); - } -} -``` - -得到的结果是出现错误: - -```java -十二月 08, 2021 3:09:29 下午 com.test.Main main -信息: 项目正在启动... -十二月 08, 2021 3:09:29 下午 com.zaxxer.hikari.HikariDataSource getConnection -信息: HikariPool-1 - Starting... -十二月 08, 2021 3:09:29 下午 com.zaxxer.hikari.HikariDataSource getConnection -信息: HikariPool-1 - Start completed. -Exception in thread "main" java.lang.RuntimeException: 我是测试异常! - at com.test.service.TestServiceImpl.test(TestServiceImpl.java:22) - at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) - at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) - at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) - at java.lang.reflect.Method.invoke(Method.java:498) - at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:344) - at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:198) - at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) - at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:123) - at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:388) - at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) - at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) - at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:215) - at com.sun.proxy.$Proxy30.test(Unknown Source) - at com.test.Main.main(Main.java:17) -``` - -我们发现,整个栈追踪信息中包含了大量aop包下的相关内容,也就印证了,它确实是通过AOP实现的,那么我们接着来看一下,数据库中的数据是否没有发生变化(出现异常回滚了) - -结果显而易见,确实被回滚了,数据库中没有任何的内容。 - -我们接着来研究一下`@Transactional`注解的一些参数: - -```java -@Target({ElementType.TYPE, ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -@Inherited -@Documented -public @interface Transactional { - @AliasFor("transactionManager") - String value() default ""; - - @AliasFor("value") - String transactionManager() default ""; - - String[] label() default {}; - - Propagation propagation() default Propagation.REQUIRED; - - Isolation isolation() default Isolation.DEFAULT; - - int timeout() default -1; - - String timeoutString() default ""; - - boolean readOnly() default false; - - Class[] rollbackFor() default {}; - - String[] rollbackForClassName() default {}; - - Class[] noRollbackFor() default {}; - - String[] noRollbackForClassName() default {}; -} -``` - -我们来讲解几个比较关键的信息: - -* transactionManager:指定事务管理器 -* propagation:事务传播规则,一个事务可以包括N个子事务 -* isolation:事务隔离级别,不多说了 -* timeout:事务超时时间 -* readOnly:是否为只读事务,不同的数据库会根据只读属性进行优化,比如MySQL一旦声明事务为只读,那么久不允许增删改操作了。 -* rollbackFor和noRollbackFor:发生指定异常时回滚或是不回滚,默认发生任何异常都回滚 - -除了事务的传播规则,其他的内容其实已经给大家讲解过了,那么我们就来看看事务的传播。事务传播一共有七种级别: - -![这里写图片描述](https://img-blog.csdn.net/20170420212829825?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvc29vbmZseQ==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast) - -Spring默认的传播级别是`PROPAGATION_REQUIRED`,那么我们来看看,它是如何传播的,现在我们的`Service`类中一共存在两个事务,而一个事务方法包含了另一个事务方法: - -```java -@Transactional -public void test() { - test2(); - if(true) throw new RuntimeException("我是测试异常!"); //发生异常时,会回滚另一个事务吗? -} - -@Transactional -public void test2() { - mapper.insertStudent(); -} -``` - -最后我们得到结果,另一个事务被回滚了,也就是说,相当于另一个事务直接加入到此事务中了,也就是表中所描述的那样。 - -如果单独执行`test2()`则会开启一个新的事务,而执行`test()`则会直接让内部的`test2()`加入到当前事务中。 - -```java -@Transactional -public void test() { - test2(); -} - -@Transactional(propagation = Propagation.SUPPORTS) -public void test2() { - mapper.insertStudent(); - if(true) throw new RuntimeException("我是测试异常!"); -} -``` - -现在我们将`test2()`的传播级别设定为`SUPPORTS`,那么这时如果单独调用`test2()`方法,并不会以事务的方式执行,当发生异常时,虽然依然存在AOP增强,但是不会进行回滚操作,而现在再调用`test()`方法,才会以事务的方式执行。 - -我们接着来看`MANDATORY`,它非常严格,如果当前方法并没有在任何事务中进行,会直接出现异常: - -```java -@Transactional -public void test() { - test2(); -} - -@Transactional(propagation = Propagation.MANDATORY) -public void test2() { - mapper.insertStudent(); - if(true) throw new RuntimeException("我是测试异常!"); -} -``` - -直接运行`test2()`方法,报错如下: - -```java -Exception in thread "main" org.springframework.transaction.IllegalTransactionStateException: No existing transaction found for transaction marked with propagation 'mandatory' - at org.springframework.transaction.support.AbstractPlatformTransactionManager.getTransaction(AbstractPlatformTransactionManager.java:362) - at org.springframework.transaction.interceptor.TransactionAspectSupport.createTransactionIfNecessary(TransactionAspectSupport.java:595) - at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:382) - at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) - at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) - at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:215) - at com.sun.proxy.$Proxy29.test2(Unknown Source) - at com.test.Main.main(Main.java:17) -``` - -`NESTED`级别表示如果存在外层事务,则此方法单独创建一个子事务,回滚只会影响到此子事务,实际上就是利用创建Savepoint,然后回滚到此保存点实现的。`NEVER`级别表示此方法不应该加入到任何事务中,其余类型适用于同时操作多数据源情况下的分布式事务管理,这里暂时不做介绍。 - -至此,有关Spring的核心内容就讲解完毕了。 - -*** - -## 集成JUnit测试 - -既然使用了Spring,那么怎么集成到JUnit中进行测试呢,首先大家能够想到的肯定是: - -```java -public class TestMain { - - @Test - public void test(){ - ApplicationContext context = new AnnotationConfigApplicationContext(TestConfiguration.class); - TestService service = context.getBean(TestService.class); - service.test(); - } -} -``` - -直接编写一个测试用例即可,但是这样的话,如果我们有很多个测试用例,那么我们不可能每次测试都去创建ApplicationContext吧?我们可以使用`@Before`添加一个测试前动作来提前配置ApplicationContext,但是这样的话,还是不够简便,能不能有更快速高效的方法呢? - -Spring为我们提供了一个Test模块,它会自动集成Junit进行测试,我们可以导入一下依赖: - -```xml - - org.junit.jupiter - junit-jupiter - 5.8.1 - test - - - org.springframework - spring-test - 5.3.12 - -``` - -这里导入的是JUnit5和SpringTest模块依赖,然后直接在我们的测试类上添加两个注解就可以搞定: - -```java -@ExtendWith(SpringExtension.class) -@ContextConfiguration(classes = TestConfiguration.class) -public class TestMain { - - @Autowired - TestService service; - - @Test - public void test(){ - service.test(); - } -} -``` - -`@ExtendWith`是由JUnit提供的注解,等同于旧版本的`@RunWith`注解,然后使用SpringTest模块提供的`@ContextConfiguration`注解来表示要加载哪一个配置文件,可以是XML文件也可以是类,我们这里就直接使用类进行加载。 - -配置完成后,我们可以直接使用`@Autowired`来进行依赖注入,并且直接在测试方法中使用注入的Bean,现在就非常方便了。 - -*** - -## 探究Spring原理 - -**注意:**本版块难度很大,作为选学内容。 - -如果学习Spring基本内容对你来说已经非常困难了,建议跳过此小节,直接进入MVC阶段的学习,此小节会从源码角度解释Spring的整个运行原理,对初学者来说等同于小学跨越到高中,它并不是必学内容,但是对于个人开发能力的提升极为重要(推荐完成整个SSM阶段的学习并且加以实战之后再来看此部分),如果你还是觉得自己能够跟上节奏继续深入钻研底层原理,那么现在就开始吧。 - -### 探究IoC原理 - -首先我们大致了解一下ApplicationContext的加载流程: - -![img](https://upload-images.jianshu.io/upload_images/749633-e707e5a84194d13a.png?imageMogr2/auto-orient/strip|imageView2/2/w/852) - -我们可以看到,整个过程极为复杂,一句话肯定是无法解释的,所以我们就从`ApplicationContext`说起吧。 - -由于Spring的源码极为复杂,因此我们不可能再像了解其他框架那样直接自底向上逐行干源码了(可以自己点开看看,代码量非常之多),我们可以先从一些最基本的接口定义开始讲起,自顶向下逐步瓦解,那么我们来看看`ApplicationContext`最顶层接口是什么,一直往上,我们会发现有一个`AbstractApplicationContext`类,我们直接右键生成一个UML类图查看: - -![image-20211209202503121](/Users/nagocoler/Library/Application Support/typora-user-images/image-20211209202503121.png) - -我们发现最顶层实际上是一个BeanFactory接口,那么我们就从这个接口开始研究起。 - -我们可以看到此接口中定义了很多的行为: - -```java -public interface BeanFactory { - String FACTORY_BEAN_PREFIX = "&"; - - Object getBean(String var1) throws BeansException; - - T getBean(String var1, Class var2) throws BeansException; - - Object getBean(String var1, Object... var2) throws BeansException; - - T getBean(Class var1) throws BeansException; - - T getBean(Class var1, Object... var2) throws BeansException; - - ObjectProvider getBeanProvider(Class var1); - - ObjectProvider getBeanProvider(ResolvableType var1); - - boolean containsBean(String var1); - - boolean isSingleton(String var1) throws NoSuchBeanDefinitionException; - - boolean isPrototype(String var1) throws NoSuchBeanDefinitionException; - - boolean isTypeMatch(String var1, ResolvableType var2) throws NoSuchBeanDefinitionException; - - boolean isTypeMatch(String var1, Class var2) throws NoSuchBeanDefinitionException; - - @Nullable - Class getType(String var1) throws NoSuchBeanDefinitionException; - - @Nullable - Class getType(String var1, boolean var2) throws NoSuchBeanDefinitionException; - - String[] getAliases(String var1); -} -``` - -我们发现,其中最眼熟的就是`getBean()`方法了,此方法被重载了很多次,可以接受多种参数,因此,我们可以断定,一个IoC容器最基本的行为在此接口中已经被定义好了,也就是说,所有的BeanFactory实现类都应该具备容器管理Bean的基本能力,就像它的名字一样,它就是一个Bean工厂,工厂就是用来生产Bean实例对象的。 - -我们可以直接找到此接口的一个抽象实现`AbstractBeanFactory`类,它实现了`getBean()`方法: - -```java -public Object getBean(String name) throws BeansException { - return this.doGetBean(name, (Class)null, (Object[])null, false); -} -``` - -那么我们`doGetBean()`接着来看方法里面干了什么: - -```java -protected T doGetBean(String name, @Nullable Class requiredType, @Nullable Object[] args, boolean typeCheckOnly) throws BeansException { - String beanName = this.transformedBeanName(name); - Object sharedInstance = this.getSingleton(beanName); - Object beanInstance; - if (sharedInstance != null && args == null) { - ... -``` - -因为所有的Bean默认都是单例模式,对象只会存在一个,因此它会先调用父类的`getSingleton()`方法来直接获取单例对象,如果有的话,就可以直接拿到Bean的实例。如果没有会进入else代码块,我们接着来看,首先会进行一个判断: - -```java -if (this.isPrototypeCurrentlyInCreation(beanName)) { - throw new BeanCurrentlyInCreationException(beanName); -} -``` - -这是为了解决循环依赖进行的处理,比如A和B都是以原型模式进行创建,而A中需要注入B,B中需要注入A,这时就会出现A还未创建完成,就需要B,而B这时也没创建完成,因为B需要A,而A等着B,这样就只能无限循环下去了,所以就出现了循环依赖的问题(同理,一个对象,多个对象也会出现这种情况)但是在单例模式下,由于每个Bean只会创建一个实例,Spring完全有机会处理好循环依赖的问题,只需要一个正确的赋值操作实现循环即可。那么单例模式下是如何解决循环依赖问题的呢? - -![img](https://pica.zhimg.com/80/v2-a426f1c897402e708f3781581211008f_1440w.jpg?source=1940ef5c) - -我们回到`getSingleton()`方法中,单例模式是可以自动解决循环依赖问题的: - -```java -@Nullable -protected Object getSingleton(String beanName, boolean allowEarlyReference) { - Object singletonObject = this.singletonObjects.get(beanName); - //先从第一层列表中拿Bean实例,拿到直接返回 - if (singletonObject == null && this.isSingletonCurrentlyInCreation(beanName)) { - //第一层拿不到,并且已经认定为处于循环状态,看看第二层有没有 - singletonObject = this.earlySingletonObjects.get(beanName); - if (singletonObject == null && allowEarlyReference) { - synchronized(this.singletonObjects) { - //加锁再执行一次上述流程 - singletonObject = this.singletonObjects.get(beanName); - if (singletonObject == null) { - singletonObject = this.earlySingletonObjects.get(beanName); - if (singletonObject == null) { - //仍然没有获取到实例,只能从singletonFactory中获取了 - ObjectFactory singletonFactory = (ObjectFactory)this.singletonFactories.get(beanName); - if (singletonFactory != null) { - singletonObject = singletonFactory.getObject(); - //丢进earlySingletonObjects中,下次就可以直接在第二层拿到了 - this.earlySingletonObjects.put(beanName, singletonObject); - this.singletonFactories.remove(beanName); - } - } - } - } - } - } - - return singletonObject; -} -``` - -看起来很复杂,实际上它使用了三层列表的方式来处理循环依赖的问题。包括: - -* singletonObjects -* earlySingletonObjects -* singletonFactories - -当第一层拿不到时,会接着判断这个Bean是否处于创建状态`isSingletonCurrentlyInCreation()`,它会从一个Set集合中查询,这个集合中存储了已经创建但还未注入属性的实例对象,也就是说处于正在创建状态,如果说发现此Bean处于正在创建状态(一定是因为某个Bean需要注入这个Bean的实例),就可以断定它应该是出现了循环依赖的情况。 - -earlySingletonObjects相当于是专门处理循环依赖的表,一般包含singletonObjects中的全部实例,如果这个里面还是没有,接着往下走,这时会从singletonFactories中获取(所有的Bean初始化完成之后都会被丢进singletonFactories,也就是只创建了,但是还没进行依赖注入的时候)在获取到后,向earlySingletonObjects中丢入此Bean的实例,并将实例从singletonFactories中移除。 - -我们最后再来梳理一下流程,还是用我们刚才的例子,A依赖于B,B依赖于A: - -1. 假如A先载入,那么A首先进入了singletonFactories中,注意这时还没进行依赖注入,A中的B还是null - * singletonFactories:A - * earlySingletonObjects: - * singletonObjects: -2. 接着肯定是注入A的依赖B了,但是B还没初始化,因此现在先把B给载入了,B构造完成后也进了singletonFactories - * singletonFactories:A,B - * earlySingletonObjects: - * singletonObjects: -3. 开始为B注入依赖,发现B依赖于A,这时又得去获取A的实例,根据上面的分析,这时候A还在singletonFactories中,那么它会被丢进earlySingletonObjects,然后从singletonFactories中移除,然后返回A的实例(注意此时A中的B依赖还是null) - * singletonFactories:B - * earlySingletonObjects:A - * singletonObjects: -4. 这时B已经完成依赖注入了,因此可以直接丢进singletonObjects中 - * singletonFactories: - * earlySingletonObjects:A - * singletonObjects:B -5. 然后再将B注入到A中,完成A的依赖注入,A也被丢进singletonObjects中,至此循环依赖完成,A和B完成实例创建 - * singletonFactories: - * earlySingletonObjects: - * singletonObjects:A,B - -经过整体过程梳理,关于Spring如何解决单例模式的循环依赖理解起来就非常简单了。 - -现在让我们回到之前的地方,原型模式下如果出现循环依赖会直接抛出异常,如果不存在会接着向下: - -```java -//BeanFactory存在父子关系 -BeanFactory parentBeanFactory = this.getParentBeanFactory(); -//如果存在父BeanFactory,同时当前BeanFactory没有这个Bean的定义 -if (parentBeanFactory != null && !this.containsBeanDefinition(beanName)) { - //这里是因为Bean可能有别名,找最原始的那个名称 - String nameToLookup = this.originalBeanName(name); - if (parentBeanFactory instanceof AbstractBeanFactory) { - //向父BeanFactory递归查找 - return ((AbstractBeanFactory)parentBeanFactory).doGetBean(nameToLookup, requiredType, args, typeCheckOnly); - } - - if (args != null) { - //根据参数查找 - return parentBeanFactory.getBean(nameToLookup, args); - } - - if (requiredType != null) { - //根据类型查找 - return parentBeanFactory.getBean(nameToLookup, requiredType); - } - - //各种找 - return parentBeanFactory.getBean(nameToLookup); -} -``` - -也就是说,BeanFactory会先看当前是否存在Bean的定义,如果没有会直接用父BeanFactory各种找。这里出现了一个新的接口`BeanDefinition`,既然工厂需要生产商品,那么肯定需要拿到商品的原材料以及制作配方,我们的Bean也是这样,Bean工厂需要拿到Bean的信息才可以去生成这个Bean的实例对象,而`BeanDefinition`就是用于存放Bean的信息的,所有的Bean信息正是从XML配置文件读取或是注解扫描后得到的。 - -我们接着来看,如果此BeanFactory不存在父BeanFactory或是包含了Bean的定义,那么会接着往下走,这时只能自己创建Bean了,首先会拿到一个`RootBeanDefinition`对象: - -```java -try { - if (requiredType != null) { - beanCreation.tag("beanType", requiredType::toString); - } - - RootBeanDefinition mbd = this.getMergedLocalBeanDefinition(beanName); -``` - -下面的内容就非常复杂了,但是我们可以知道,它一定是根据对应的类型(单例、原型)进行了对应的处理,最后自行创建一个新的对象返回。一个Bean的加载流程为: - -首先拿到`BeanDefinition`定义,选择对应的构造方法,通过反射进行实例化,然后进行属性填充(依赖注入),完成之后再调用初始化方法(init-method),最后如果存在AOP,则会生成一个代理对象,最后返回的才是我们真正得到的Bean对象。 - -最后让我们回到`ApplicationContext`中,实际上,它就是一个强化版的`BeanFactory`,在最基本的Bean管理基础上,还添加了: - -* 国际化(MessageSource) -* 访问资源,如URL和文件(ResourceLoader) -* 载入多个(有继承关系)上下文 -* 消息发送、响应机制(ApplicationEventPublisher) -* AOP机制 - -我们发现,无论是还是的构造方法中都会调用`refresh()`方法来刷新应用程序上下文: - -```java -public AnnotationConfigApplicationContext(Class... componentClasses) { - this(); - this.register(componentClasses); - this.refresh(); -} -``` - -此方法在讲解完AOP原理之后,再进行讲解。综上,有关IoC容器的大部分原理就讲解完毕了。 - -### 探究AOP原理 - -前面我们提到了`PostProcessor`,它其实是Spring提供的一种后置处理机制,它可以让我们能够插手Bean、BeanFactory、BeanDefinition的创建过程,相当于进行一个最终的处理,而最后得到的结果(比如Bean实例、Bean定义等)就是经过后置处理器返回的结果,它是整个加载过程的最后一步。 - -而AOP机制正是通过它来实现的,我们首先来认识一下第一个接口`BeanPostProcessor`,它相当于Bean初始化的一个后置动作,我们可以直接实现此接口: - -```java -//注意它后置处理器也要进行注册 -@Component -public class TestBeanProcessor implements BeanPostProcessor { - @Override - public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { - System.out.println(beanName); //打印bean的名称 - return bean; - } - - @Override - public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { - return BeanPostProcessor.super.postProcessBeforeInitialization(bean, beanName); - } -} -``` - -我们发现,此接口中包括两个方法,一个是`postProcessAfterInitialization`用于在Bean初始化之后进行处理,还有一个`postProcessBeforeInitialization`用于在Bean初始化之前进行处理,注意这里的初始化不是创建对象,而是调用类的初始化方法,比如: - -```java -@Component -public class TestBeanProcessor implements BeanPostProcessor { - @Override - public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { - System.out.println("我是之后:"+beanName); - return bean; //这里返回的Bean相当于最终的结果了,我们依然能够插手修改,这里返回之后是什么就是什么了 - } - - @Override - public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { - System.out.println("我是之前:"+beanName); - return bean; //这里返回的Bean会交给下一个阶段,也就是初始化方法 - } -} -``` - -```java -@Component -public class TestServiceImpl implements TestService{ - - public TestServiceImpl(){ - System.out.println("我是构造方法"); - } - - @PostConstruct - public void init(){ - System.out.println("我是初始化方法"); - } - - TestMapper mapper; - - @Autowired - public void setMapper(TestMapper mapper) { - System.out.println("我是依赖注入"); - this.mapper = mapper; - } - - ... -``` - -而TestServiceImpl的加载顺序为: - -```xml -我是构造方法 -我是依赖注入 -我是之前:testServiceImpl -我是初始化方法 -我是之后:testServiceImpl -``` - -现在我们再来总结一下一个Bean的加载流程: - -[Bean定义]首先扫描Bean,加载Bean定义 -> [依赖注入]根据Bean定义通过反射创建Bean实例 -> [依赖注入]进行依赖注入(顺便解决循环依赖问题)-> [初始化Bean]BeanPostProcessor的初始化之前方法 -> [初始化Bean]Bean初始化方法 -> [初始化Bean]BeanPostProcessor的初始化之前后方法 -> [完成]最终得到的Bean加载完成的实例 - -利用这种机制,理解Aop的实现过程就非常简单了,AOP实际上也是通过这种机制实现的,它的实现类是`AnnotationAwareAspectJAutoProxyCreator`,而它就是在最后对Bean进行了代理,因此最后我们得到的结果实际上就是一个动态代理的对象(有关详细实现过程,这里就不进行列举了,感兴趣的可以继续深入) - -那么肯定有人有疑问了,这个类没有被注册啊,那按理说它不应该参与到Bean的初始化流程中的,为什么它直接就被加载了呢? - -还记得`@EnableAspectJAutoProxy`吗?我们来看看它是如何定义就知道了: - -```java -@Target({ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -@Documented -@Import({AspectJAutoProxyRegistrar.class}) -public @interface EnableAspectJAutoProxy { - boolean proxyTargetClass() default false; - - boolean exposeProxy() default false; -} -``` - -我们发现它使用了`@Import`来注册`AspectJAutoProxyRegistrar`,那么这个类又是什么呢,我们接着来看: - -```java -class AspectJAutoProxyRegistrar implements ImportBeanDefinitionRegistrar { - AspectJAutoProxyRegistrar() { - } - - public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { - //注册AnnotationAwareAspectJAutoProxyCreator到容器中 - AopConfigUtils.registerAspectJAnnotationAutoProxyCreatorIfNecessary(registry); - AnnotationAttributes enableAspectJAutoProxy = AnnotationConfigUtils.attributesFor(importingClassMetadata, EnableAspectJAutoProxy.class); - if (enableAspectJAutoProxy != null) { - if (enableAspectJAutoProxy.getBoolean("proxyTargetClass")) { - AopConfigUtils.forceAutoProxyCreatorToUseClassProxying(registry); - } - - if (enableAspectJAutoProxy.getBoolean("exposeProxy")) { - AopConfigUtils.forceAutoProxyCreatorToExposeProxy(registry); - } - } - - } -} -``` - -它实现了接口,这个接口也是Spring提供的一种Bean加载机制,它支持直接向容器中添加Bean定义,容器也会加载这个Bean: - -* ImportBeanDefinitionRegistrar类只能通过其他类@Import的方式来加载,通常是启动类或配置类。 -* 使用@Import,如果括号中的类是ImportBeanDefinitionRegistrar的实现类,则会调用接口中方法(一般用于注册Bean) -* 实现该接口的类拥有注册bean的能力。 - -我们可以看到此接口提供了一个`BeanDefinitionRegistry`正是用于注册Bean的定义的。 - -因此,当我们打上了`@EnableAspectJAutoProxy`注解之后,首先会通过`@Import`加载AspectJAutoProxyRegistrar,然后调用其`registerBeanDefinitions`方法,然后使用工具类注册AnnotationAwareAspectJAutoProxyCreator到容器中,这样在每个Bean创建之后,如果需要使用AOP,那么就会通过AOP的后置处理器进行处理,最后返回一个代理对象。 - -我们也可以尝试编写一个自己的ImportBeanDefinitionRegistrar实现,首先编写一个测试Bean: - -```java -public class TestBean { - - @PostConstruct - void init(){ - System.out.println("我被初始化了!"); - } -} -``` - -```java -public class TestBeanDefinitionRegistrar implements ImportBeanDefinitionRegistrar { - - @Override - public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { - BeanDefinition definition = BeanDefinitionBuilder.rootBeanDefinition(Student.class).getBeanDefinition(); - registry.registerBeanDefinition("lbwnb", definition); - } -} -``` - -观察控制台输出,成功加载Bean实例。 - -与`BeanPostProcessor`差不多的还有`BeanFactoryPostProcessor`,它和前者一样,也是用于我们自己处理后置动作的,不过这里是用于处理BeanFactory加载的后置动作,`BeanDefinitionRegistryPostProcessor`直接继承自`BeanFactoryPostProcessor`,并且还添加了新的动作`postProcessBeanDefinitionRegistry`,你可以在这里动态添加Bean定义或是修改已经存在的Bean定义,这里我们就直接演示`BeanDefinitionRegistryPostProcessor`的实现: - -```java -@Component -public class TestDefinitionProcessor implements BeanDefinitionRegistryPostProcessor { - @Override - public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException { - System.out.println("我是Bean定义后置处理!"); - BeanDefinition definition = BeanDefinitionBuilder.rootBeanDefinition(TestBean.class).getBeanDefinition(); - registry.registerBeanDefinition("lbwnb", definition); - } - - @Override - public void postProcessBeanFactory(ConfigurableListableBeanFactory configurableListableBeanFactory) throws BeansException { - System.out.println("我是Bean工厂后置处理!"); - } -} -``` - -在这里注册Bean定义其实和之前那种方法效果一样。 - -最后,我们再完善一下Bean加载流程(加粗部分是新增的): - -[Bean定义]首先扫描Bean,加载Bean定义 -> **[Bean定义]Bean定义和Bean工厂后置处理** -> [依赖注入]根据Bean定义通过反射创建Bean实例 -> [依赖注入]进行依赖注入(顺便解决循环依赖问题)-> [初始化Bean]BeanPostProcessor的初始化之前方法 -> [初始化Bean]Bean初始化方法 -> [初始化Bean]BeanPostProcessor的初始化之前后方法 -> [完成]最终得到的Bean加载完成的实例 - -最后我们再来研究一下ApplicationContext中的`refresh()`方法: - -```java -public void refresh() throws BeansException, IllegalStateException { - synchronized(this.startupShutdownMonitor) { - StartupStep contextRefresh = this.applicationStartup.start("spring.context.refresh"); - this.prepareRefresh(); - ConfigurableListableBeanFactory beanFactory = this.obtainFreshBeanFactory(); - //初始化Bean工厂 - this.prepareBeanFactory(beanFactory); - - try { - this.postProcessBeanFactory(beanFactory); - StartupStep beanPostProcess = this.applicationStartup.start("spring.context.beans.post-process"); - //调用所有的Bean工厂、Bean注册后置处理器 - this.invokeBeanFactoryPostProcessors(beanFactory); - //注册Bean后置处理器(包括Spring内部的) - this.registerBeanPostProcessors(beanFactory); - beanPostProcess.end(); - //国际化支持 - this.initMessageSource(); - //监听和事件广播 - this.initApplicationEventMulticaster(); - this.onRefresh(); - this.registerListeners(); - //实例化所有的Bean - this.finishBeanFactoryInitialization(beanFactory); - this.finishRefresh(); - } catch (BeansException var10) { - if (this.logger.isWarnEnabled()) { - this.logger.warn("Exception encountered during context initialization - cancelling refresh attempt: " + var10); - } - - this.destroyBeans(); - this.cancelRefresh(var10); - throw var10; - } finally { - this.resetCommonCaches(); - contextRefresh.end(); - } - - } -} -``` - -我们可以给这些部分分别打上断点来观察一下此方法的整体加载流程。 - -### Mybatis整合原理 - -通过之前的了解,我们再来看Mybatis的`@MapperScan`是如何实现的,现在理解起来就非常简单了。 - -我们可以直接打开查看: - -```java -@Retention(RetentionPolicy.RUNTIME) -@Target({ElementType.TYPE}) -@Documented -@Import({MapperScannerRegistrar.class}) -@Repeatable(MapperScans.class) -public @interface MapperScan { - String[] value() default {}; - - String[] basePackages() default {}; - ... -``` - -我们发现,和Aop一样,它也是通过Registrar机制,通过`@Import`来进行Bean的注册,我们来看看`MapperScannerRegistrar`是个什么东西,关键代码如下: - -```java -void registerBeanDefinitions(AnnotationMetadata annoMeta, AnnotationAttributes annoAttrs, BeanDefinitionRegistry registry, String beanName) { - BeanDefinitionBuilder builder = BeanDefinitionBuilder.genericBeanDefinition(MapperScannerConfigurer.class); - builder.addPropertyValue("processPropertyPlaceHolders", true); - Class annotationClass = annoAttrs.getClass("annotationClass"); - if (!Annotation.class.equals(annotationClass)) { - builder.addPropertyValue("annotationClass", annotationClass); - } - - Class markerInterface = annoAttrs.getClass("markerInterface"); - if (!Class.class.equals(markerInterface)) { - builder.addPropertyValue("markerInterface", markerInterface); - } - - Class generatorClass = annoAttrs.getClass("nameGenerator"); - if (!BeanNameGenerator.class.equals(generatorClass)) { - builder.addPropertyValue("nameGenerator", BeanUtils.instantiateClass(generatorClass)); - } - - Class mapperFactoryBeanClass = annoAttrs.getClass("factoryBean"); - if (!MapperFactoryBean.class.equals(mapperFactoryBeanClass)) { - builder.addPropertyValue("mapperFactoryBeanClass", mapperFactoryBeanClass); - } - - String sqlSessionTemplateRef = annoAttrs.getString("sqlSessionTemplateRef"); - if (StringUtils.hasText(sqlSessionTemplateRef)) { - builder.addPropertyValue("sqlSessionTemplateBeanName", annoAttrs.getString("sqlSessionTemplateRef")); - } - - String sqlSessionFactoryRef = annoAttrs.getString("sqlSessionFactoryRef"); - if (StringUtils.hasText(sqlSessionFactoryRef)) { - builder.addPropertyValue("sqlSessionFactoryBeanName", annoAttrs.getString("sqlSessionFactoryRef")); - } - - List basePackages = new ArrayList(); - basePackages.addAll((Collection)Arrays.stream(annoAttrs.getStringArray("value")).filter(StringUtils::hasText).collect(Collectors.toList())); - basePackages.addAll((Collection)Arrays.stream(annoAttrs.getStringArray("basePackages")).filter(StringUtils::hasText).collect(Collectors.toList())); - basePackages.addAll((Collection)Arrays.stream(annoAttrs.getClassArray("basePackageClasses")).map(ClassUtils::getPackageName).collect(Collectors.toList())); - if (basePackages.isEmpty()) { - basePackages.add(getDefaultBasePackage(annoMeta)); - } - - String lazyInitialization = annoAttrs.getString("lazyInitialization"); - if (StringUtils.hasText(lazyInitialization)) { - builder.addPropertyValue("lazyInitialization", lazyInitialization); - } - - String defaultScope = annoAttrs.getString("defaultScope"); - if (!"".equals(defaultScope)) { - builder.addPropertyValue("defaultScope", defaultScope); - } - - builder.addPropertyValue("basePackage", StringUtils.collectionToCommaDelimitedString(basePackages)); - registry.registerBeanDefinition(beanName, builder.getBeanDefinition()); -} -``` - -虽然很长很多,但是这些代码都是在添加一些Bean定义的属性,而最关键的则是最上方的`MapperScannerConfigurer`,Mybatis将其Bean信息注册到了容器中,那么这个类又是干嘛的呢? - -```java -public class MapperScannerConfigurer implements BeanDefinitionRegistryPostProcessor, InitializingBean, ApplicationContextAware, BeanNameAware { - private String basePackage; -``` - -它实现了BeanDefinitionRegistryPostProcessor,也就是说它为Bean信息加载提供了后置处理,我们接着来看看它在Bean信息后置处理中做了什么: - -```java -public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) { - if (this.processPropertyPlaceHolders) { - this.processPropertyPlaceHolders(); - } - - //初始化类路径Mapper扫描器,它相当于是一个工具类,可以快速扫描出整个包下的类定义信息 - //ClassPathMapperScanner是Mybatis自己实现的一个扫描器,修改了一些扫描规则 - ClassPathMapperScanner scanner = new ClassPathMapperScanner(registry); - scanner.setAddToConfig(this.addToConfig); - scanner.setAnnotationClass(this.annotationClass); - scanner.setMarkerInterface(this.markerInterface); - scanner.setSqlSessionFactory(this.sqlSessionFactory); - scanner.setSqlSessionTemplate(this.sqlSessionTemplate); - scanner.setSqlSessionFactoryBeanName(this.sqlSessionFactoryBeanName); - scanner.setSqlSessionTemplateBeanName(this.sqlSessionTemplateBeanName); - scanner.setResourceLoader(this.applicationContext); - scanner.setBeanNameGenerator(this.nameGenerator); - scanner.setMapperFactoryBeanClass(this.mapperFactoryBeanClass); - if (StringUtils.hasText(this.lazyInitialization)) { - scanner.setLazyInitialization(Boolean.valueOf(this.lazyInitialization)); - } - - if (StringUtils.hasText(this.defaultScope)) { - scanner.setDefaultScope(this.defaultScope); - } - - //添加过滤器,这里会配置为所有的接口都能被扫描(因此即使你不添加@Mapper注解都能够被扫描并加载) - scanner.registerFilters(); - //开始扫描 - scanner.scan(StringUtils.tokenizeToStringArray(this.basePackage, ",; \t\n")); -} -``` - -开始扫描后,会调用`doScan()`方法,我们接着来看(这是`ClassPathMapperScanner`中的扫描方法): - -```java -public Set doScan(String... basePackages) { - Set beanDefinitions = super.doScan(basePackages); - //首先从包中扫描所有的Bean定义 - if (beanDefinitions.isEmpty()) { - LOGGER.warn(() -> { - return "No MyBatis mapper was found in '" + Arrays.toString(basePackages) + "' package. Please check your configuration."; - }); - } else { - //处理所有的Bean定义,实际上就是生成对应Mapper的代理对象,并注册到容器中 - this.processBeanDefinitions(beanDefinitions); - } - - //最后返回所有的Bean定义集合 - return beanDefinitions; -} -``` - -通过断点我们发现,最后处理得到的Bean定义发现此Bean是一个MapperFactoryBean,它不同于普通的Bean,FactoryBean相当于为普通的Bean添加了一层外壳,它并不是依靠Spring直接通过反射创建,而是使用接口中的方法: - -```java -public interface FactoryBean { - String OBJECT_TYPE_ATTRIBUTE = "factoryBeanObjectType"; - - @Nullable - T getObject() throws Exception; - - @Nullable - Class getObjectType(); - - default boolean isSingleton() { - return true; - } -} -``` - -通过`getObject()`方法,就可以获取到Bean的实例了。 - -注意这里一定要区分FactoryBean和BeanFactory的概念: - -* BeanFactory是个Factory,也就是 IOC 容器或对象工厂,所有的 Bean 都是由 BeanFactory( 也就是 IOC 容器 ) 来进行管理。 -* FactoryBean是一个能生产或者修饰生成对象的工厂Bean(本质上也是一个Bean),可以在BeanFactory(IOC容器)中被管理,所以它并不是一个简单的Bean。当使用容器中factory bean的时候,该容器不会返回factory bean本身,而是返回其生成的对象。要想获取FactoryBean的实现类本身,得在getBean(String BeanName)中的BeanName之前加上&,写成getBean(String &BeanName)。 - -我们也可以自己编写一个实现: - -```java -@Component("test") -public class TestFb implements FactoryBean { - @Override - public Student getObject() throws Exception { - System.out.println("获取了学生"); - return new Student(); - } - - @Override - public Class getObjectType() { - return Student.class; - } -} -``` - -```java -public static void main(String[] args) { - log.info("项目正在启动..."); - ApplicationContext context = new AnnotationConfigApplicationContext(TestConfiguration.class); - System.out.println(context.getBean("&test")); //得到FactoryBean本身(得加个&搞得像C语言指针一样) - System.out.println(context.getBean("test")); //得到FactoryBean调用getObject()之后的结果 -} -``` - -因此,实际上我们的Mapper最终就以FactoryBean的形式,被注册到容器中进行加载了: - -```java -public T getObject() throws Exception { - return this.getSqlSession().getMapper(this.mapperInterface); -} -``` - -这样,整个Mybatis的`@MapperScan`的原理就全部解释完毕了。 - -在了解完了Spring的底层原理之后,我们其实已经完全可以根据这些实现原理来手写一个Spring框架了。 \ No newline at end of file diff --git a/青空笔记/JavaSSM笔记/JavaSSM笔记(三).md b/青空笔记/JavaSSM笔记/JavaSSM笔记(三).md deleted file mode 100644 index a5fa0da..0000000 --- a/青空笔记/JavaSSM笔记/JavaSSM笔记(三).md +++ /dev/null @@ -1,1158 +0,0 @@ -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fp3.ssl.qhimg.com%2Ft0166542ac0759ab525.png&refer=http%3A%2F%2Fp3.ssl.qhimg.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1642428815&t=f486dc0d3f2581c0afaf0cce7eaa0d57) - -# SpringSecurity - -本章我们会一边讲解SpringSecurity框架,一边从头开始编写图书管理系统。 - - SpringSecurity是一个基于Spring开发的非常强大的权限验证框架,其核心功能包括: - -- 认证 (用户登录) -- 授权 (此用户能够做哪些事情) -- 攻击防护 (防止伪造身份攻击) - -我们为什么需要使用更加专业的全新验证框架,还要从CSRF说起。 - -## CSRF跨站请求伪造攻击 - -我们时常会在QQ上收到别人发送的钓鱼网站链接,只要你在上面登陆了你的QQ账号,那么不出意外,你的号已经在别人手中了。实际上这一类网站都属于恶意网站,专门用于盗取他人信息,执行非法操作,甚至获取他人账户中的财产,非法转账等。而这里,我们需要了解一种比较容易发生的恶意操作,从不法分子的角度去了解整个流程。 - -我们在JavaWeb阶段已经了解了Session和Cookie的机制,在一开始的时候,服务端会给浏览器一个名为JSESSION的Cookie信息作为会话的唯一凭据,只要用户携带此Cookie访问我们的网站,那么我们就可以认定此会话属于哪个浏览器。因此,只要此会话的用户执行了登录操作,那么就可以随意访问个人信息等内容。 - -比如现在,我们的服务器新增了一个转账的接口,用户登录之后,只需要使用POST请求携带需要转账的金额和转账人访问此接口就可以进行转账操作: - -```java -@RequestMapping("/index") -public String index(HttpSession session){ - session.setAttribute("login", true); //这里就正常访问一下index表示登陆 - return "index"; -} -``` - -```java -@RequestMapping(value = "/pay", method = RequestMethod.POST, produces = "text/html;charset=utf-8") //这里要设置一下produces不然会乱码 -@ResponseBody -public String pay(String account, - int amount, - @SessionAttribute("login") Boolean isLogin){ - if (isLogin) return "成功转账 ¥"+amount+" 给:"+account; - else return "转账失败,您没有登陆!"; -} -``` - -那么,大家有没有想过这样一个问题,我们为了搜索学习资料时可能一不小心访问了一个恶意网站,而此网站携带了这样一段内容: - -```html - - - - - 我是(恶)学(意)习网站 - - -

- - -``` - -注意这个页面并不是我们官方提供的页面,而是不法分子搭建的恶意网站。我们发现此页面中有一个表单,但是表单中的两个输入框被隐藏了,而我们看到的只有一个按钮,我们不知道这是一个表单,也不知道表单会提交给那个地址,这时整个页面就非常有迷惑性了。如果我们点击此按钮,那么整个表单的数据会以POST的形式发送给我们的服务端(会携带之前登陆我们网站的Cookie信息),但是这里很明显是另一个网站跳转,通过这样的方式,恶意网站就成功地在我们毫不知情的情况下引导我们执行了转账操作,当你发现上当受骗时,钱已经被转走了。 - -而这种构建恶意页面,引导用户访问对应网站执行操作的方式称为:**跨站请求伪造**(CSRF,Cross Site Request Forgery) - -显然,我们之前编写的图书管理系统就存在这样的安全漏洞,而SpringSecurity就很好地解决了这样的问题。 - -*** - -## 开发环境搭建 - -我们依然使用之前的模板来搭建图书管理系统项目。 - -导入以下依赖: - -```xml - - - - - org.springframework.security - spring-security-web - 5.5.3 - - - org.springframework.security - spring-security-config - 5.5.3 - - - org.springframework - spring-webmvc - 5.3.14 - - - - - mysql - mysql-connector-java - 8.0.27 - - - org.mybatis - mybatis-spring - 2.0.6 - - - org.mybatis - mybatis - 3.5.7 - - - org.springframework - spring-jdbc - 5.3.14 - - - com.zaxxer - HikariCP - 3.4.5 - - - - - org.projectlombok - lombok - 1.18.22 - - - org.slf4j - slf4j-jdk14 - 1.7.32 - - - - - javax.servlet - javax.servlet-api - 4.0.1 - provided - - - - - org.junit.jupiter - junit-jupiter-api - ${junit.version} - test - - - org.junit.jupiter - junit-jupiter-engine - ${junit.version} - test - - -``` - -接着创建Initializer来配置Web应用程序: - -```java -public class MvcInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { - - @Override - protected Class[] getRootConfigClasses() { - return new Class[]{RootConfiguration.class}; - } - - @Override - protected Class[] getServletConfigClasses() { - return new Class[]{MvcConfiguration.class}; - } - - @Override - protected String[] getServletMappings() { - return new String[]{"/"}; - } -} -``` - -创建Mvc配置类: - -```java -@ComponentScan("book.manager.controller") -@Configuration -@EnableWebMvc -public class MvcConfiguration implements WebMvcConfigurer { - - //我们需要使用ThymeleafViewResolver作为视图解析器,并解析我们的HTML页面 - @Bean - public ThymeleafViewResolver thymeleafViewResolver(@Autowired SpringTemplateEngine springTemplateEngine){ - ThymeleafViewResolver resolver = new ThymeleafViewResolver(); - resolver.setOrder(1); - resolver.setCharacterEncoding("UTF-8"); - resolver.setTemplateEngine(springTemplateEngine); - return resolver; - } - - //配置模板解析器 - @Bean - public SpringResourceTemplateResolver templateResolver(){ - SpringResourceTemplateResolver resolver = new SpringResourceTemplateResolver(); - resolver.setSuffix(".html"); - resolver.setPrefix("/WEB-INF/template/"); - return resolver; - } - - //配置模板引擎Bean - @Bean - public SpringTemplateEngine springTemplateEngine(@Autowired ITemplateResolver resolver){ - SpringTemplateEngine engine = new SpringTemplateEngine(); - engine.setTemplateResolver(resolver); - return engine; - } - - //开启静态资源处理 - @Override - public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) { - configurer.enable(); - } - - //静态资源路径配置 - @Override - public void addResourceHandlers(ResourceHandlerRegistry registry) { - registry.addResourceHandler("/static/**").addResourceLocations("/WEB-INF/static/"); - } -} -``` - -创建Root配置类: - -```java -@Configuration -public class RootConfiguration { - -} -``` - -最后创建一个专用于响应页面的Controller即可: - -```java -/** - * 专用于处理页面响应的控制器 - */ -@Controller -public class PageController { - - @RequestMapping("/index") - public String login(){ - return "index"; - } -} -``` - -接着我们需要将前端页面放到对应的文件夹中,然后开启服务器并通过浏览器,成功访问。 - -接着我们需要配置SpringSecurity,与Mvc一样,需要一个初始化器: - -```java -public class SecurityInitializer extends AbstractSecurityWebApplicationInitializer { - //不用重写任何内容 - //这里实际上会自动注册一个Filter,SpringSecurity底层就是依靠N个过滤器实现的,我们之后再探讨 -} -``` - -接着我们需要再创建一个配置类用于配置SpringSecurity: - -```java -@Configuration -@EnableWebSecurity -public class SecurityConfiguration extends WebSecurityConfigurerAdapter { - //继承WebSecurityConfigurerAdapter,之后会进行配置 -} -``` - -接着在根容器中添加此配置文件即可: - -```java -@Override -protected Class[] getRootConfigClasses() { - return new Class[]{RootConfiguration.class, SecurityConfiguration.class}; -} -``` - -这样,SpringSecurity的配置就完成了,我们再次运行项目,会发现无法进入的我们的页面中,无论我们访问哪个页面,都会进入到SpringSecurity为我们提供的一个默认登录页面,之后我们会讲解如何进行配置。 - -至此,项目环境搭建完成。 - -*** - -## 认证 - -### 直接认证 - -既然我们的图书管理系统要求用户登录之后才能使用,所以我们首先要做的就是实现用户验证,要实现用户验证,我们需要进行一些配置: - -```java -@Override -protected void configure(AuthenticationManagerBuilder auth) throws Exception { - BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); //这里使用SpringSecurity提供的BCryptPasswordEncoder - auth - .inMemoryAuthentication() //直接验证方式,之后会讲解使用数据库验证 - .passwordEncoder(encoder) //密码加密器 - .withUser("test") //用户名 - .password(encoder.encode("123456")) //这里需要填写加密后的密码 - .roles("user"); //用户的角色(之后讲解) -} -``` - -SpringSecurity的密码校验并不是直接使用原文进行比较,而是使用加密算法将密码进行加密(更准确地说应该进行Hash处理,此过程是不可逆的,无法解密),最后将用户提供的密码以同样的方式加密后与密文进行比较。对于我们来说,用户提供的密码属于隐私信息,直接明文存储并不好,而且如果数据库内容被窃取,那么所有用户的密码将全部泄露,这是我们不希望看到的结果,我们需要一种既能隐藏用户密码也能完成认证的机制,而Hash处理就是一种很好的解决方案,通过将用户的密码进行Hash值计算,计算出来的结果无法还原为原文,如果需要验证是否与此密码一致,那么需要以同样的方式加密再比较两个Hash值是否一致,这样就很好的保证了用户密码的安全性。 - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimages.10qianwan.com%2F10qianwan%2F20180223%2Fb_1_201802231459287319.jpg&refer=http%3A%2F%2Fimages.10qianwan.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1643122575&t=da2d04d86d4869d3054fb9a861ad1824) - -我们这里使用的是SpringSecurity提供的BCryptPasswordEncoder,至于加密过程,这里不做深入讲解。 - -现在,我们可以尝试使用此账号登录,在登录后,就可以随意访问我们的网站内容了。 - -### 使用数据库认证 - -前面我们已经实现了直接认证的方式,那么如何将其连接到数据库,通过查询数据库中的内容来进行用户登录呢? - -首先我们需要将加密后的密码添加到数据库中作为用户密码: - -```java -public class MainTest { - - @Test - public void test(){ - BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); - System.out.println(encoder.encode("123456")); - } -} -``` - -这里编写一个测试来完成。 - -然后我们需要创建一个Service实现,实现的是UserDetailsService,它支持我们自己返回一个UserDetails对象,我们只需直接返回一个包含数据库中的用户名、密码等信息的UserDetails即可,SpringSecurity会自动进行比对。 - -```java -@Service -public class UserAuthService implements UserDetailsService { - - @Resource - UserMapper mapper; - - @Override - public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException { - String password = mapper.getPasswordByUsername(s); //从数据库根据用户名获取密码 - if(password == null) - throw new UsernameNotFoundException("登录失败,用户名或密码错误!"); - return User //这里需要返回UserDetails,SpringSecurity会根据给定的信息进行比对 - .withUsername(s) - .password(password) //直接从数据库取的密码 - .roles("user") //用户角色 - .build(); - } -} -``` - -别忘了在配置类中进行扫描,将其注册为Bean,接着我们需要编写一个Mapper用于和数据库交互: - -```java -@Mapper -public interface UserMapper { - - @Select("select password from users where username = #{username}") - String getPasswordByUsername(String username); -} -``` - -和之前一样,配置一下Mybatis和数据源: - -```java -@ComponentScans({ - @ComponentScan("book.manager.service") -}) -@MapperScan("book.manager.mapper") -@Configuration -public class RootConfiguration { - @Bean - public DataSource dataSource(){ - HikariDataSource dataSource = new HikariDataSource(); - dataSource.setJdbcUrl("jdbc:mysql://localhost:3306/study"); - dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver"); - dataSource.setUsername("root"); - dataSource.setPassword("123456"); - return dataSource; - } - - @Bean - public SqlSessionFactoryBean sqlSessionFactoryBean(@Autowired DataSource dataSource){ - SqlSessionFactoryBean bean = new SqlSessionFactoryBean(); - bean.setDataSource(dataSource); - return bean; - } -} -``` - -最后再修改一下Security配置: - -```java -@Override -protected void configure(AuthenticationManagerBuilder auth) throws Exception { - auth - .userDetailsService(service) //使用自定义的Service实现类进行验证 - .passwordEncoder(new BCryptPasswordEncoder()); //依然使用BCryptPasswordEncoder -} -``` - -这样,登陆就会从数据库中进行查询。 - -### 自定义登录界面 - -前面我们已经了解了如何实现数据库权限验证,那么现在我们接着来看看,如何将登陆页面修改为我们自定义的样式。 - -首先我们要了解一下SpringSecurity是如何进行登陆验证的,我们可以观察一下默认的登陆界面中,表单内有哪些内容: - -```html -
- -
-``` - -我们发现,首先有一个用户名的输入框和一个密码的输入框,我们需要在其中填写用户名和密码,但是我们发现,除了这两个输入框以外,还有一个input标签,它是隐藏的,并且它存储了一串类似于Hash值的东西,名称为"_csrf",其实看名字就知道,这玩意八成都是为了防止CSRF攻击而存在的。 - -从Spring Security 4.0开始,默认情况下会启用CSRF保护,以防止CSRF攻击应用程序,Spring Security CSRF会针对PATCH,POST,PUT和DELETE方法的请求(不仅仅只是登陆请求,这里指的是任何请求路径)进行防护,而这里的登陆表单正好是一个POST类型的请求。在默认配置下,无论是否登陆,页面中只要发起了PATCH,POST,PUT和DELETE请求一定会被拒绝,并返回**403**错误**(注意,这里是个究极大坑)**,需要在请求的时候加入csrfToken才行,也就是"83421936-b84b-44e3-be47-58bb2c14571a",正是csrfToken,如果提交的是表单类型的数据,那么表单中必须包含此Token字符串,键名称为"_csrf";如果是JSON数据格式发送的,那么就需要在请求头中包含此Token字符串。 - -综上所述,我们最后提交的登陆表单,除了必须的用户名和密码,还包含了一个csrfToken字符串用于验证,防止攻击。 - -因此,我们在编写自己的登陆页面时,需要添加这样一个输入框: - -```html - -``` - -隐藏即可,但是必须要有,而Token的键名称和Token字符串可以通过Thymeleaf从Model中获取,SpringSecurity会自动将Token信息添加到Model中。 - -接着我们就可以将我们自己的页面替换掉默认的页面了,我们需要重写另一个方法来实现: - -```java -@Override -protected void configure(HttpSecurity http) throws Exception { - http - .authorizeRequests() //首先需要配置哪些请求会被拦截,哪些请求必须具有什么角色才能访问 - .antMatchers("/static/**").permitAll() //静态资源,使用permitAll来运行任何人访问(注意一定要放在前面) - .antMatchers("/**").hasRole("user") //所有请求必须登陆并且是user角色才可以访问(不包含上面的静态资源) -} -``` - -首先我们需要配置拦截规则,也就是当用户未登录时,哪些路径可以访问,哪些路径不可以访问,如果不可以访问,那么会被自动重定向到登陆页面。 - -接着我们需要配置表单登陆和登录页面: - -```java -.formLogin() //配置Form表单登陆 -.loginPage("/login") //登陆页面地址(GET) -.loginProcessingUrl("/doLogin") //form表单提交地址(POST) -.defaultSuccessUrl("/index") //登陆成功后跳转的页面,也可以通过Handler实现高度自定义 -.permitAll() //登陆页面也需要允许所有人访问 -``` - -需要配置登陆页面的地址和登陆请求发送的地址,这里登陆页面填写为`/login`,登陆请求地址为`/doLogin`,登陆页面需要我们自己去编写Controller来实现,登陆请求提交处理由SpringSecurity提供,只需要写路径就可以了。 - -```java -@RequestMapping("/login") -public String login(){ - return "login"; -} -``` - -配置好后,我们还需要配置一下退出登陆操作: - -```java -.and() -.logout() -.logoutUrl("/logout") //退出登陆的请求地址 -.logoutSuccessUrl("/login"); //退出后重定向的地址 -``` - -注意这里的退出登陆请求也必须是POST请求方式(因为开启了CSFR防护,需要添加Token),否则无法访问,这里主页面就这样写: - -```html - -
- - -
- - -``` - -登陆成功后,点击退出登陆按钮,就可以成功退出并回到登陆界面了。 - -由于我们在学习的过程中暂时用不到CSFR防护,因此可以将其关闭,这样直接使用get请求也可以退出登陆,并且登陆请求中无需再携带Token了,推荐关闭,因为不关闭后面可能会因为没考虑CSRF防护而遇到一连串的问题: - -```java -.and() -.csrf().disable(); -``` - -这样就可以直接关闭此功能了,但是注意,这样将会导致您的Web网站存在安全漏洞。(这里为了之后省事,就关闭保护了,但是一定要记得在不关闭的情况下需要携带Token访问) - -*** - -## 授权 - -用户登录后,可能会根据用户当前是身份进行角色划分,比如我们最常用的QQ,一个QQ群里面,有群主、管理员和普通群成员三种角色,其中群主具有最高权限,群主可以管理整个群的任何板块,并且具有解散和升级群的资格,而管理员只有群主的一部分权限,只能用于日常管理,普通群成员则只能进行最基本的聊天操作。 - -对于我们来说,用户的一个操作实际上就是在访问我们提供的`接口`(编写的对应访问路径的Servlet),比如登陆,就需要调用`/login`接口,退出登陆就要调用/`logout`接口,而我们之前的图书管理系统中,新增图书、删除图书,所有的操作都有着对应的Servlet来进行处理。因此,从我们开发者的角度来说,决定用户能否使用某个功能,只需要决定用户是否能够访问对应的Servlet即可。 - -我们可以大致像下面这样进行划分: - -* 群主:`/login`、`/logout`、`/chat`、`/edit`、`/delete`、`/upgrade` -* 管理员:`/login`、`/logout`、`/chat`、`/edit` -* 普通群成员:`/login`、`/logout`、`/chat` - -也就是说,我们需要做的就是指定哪些请求可以由哪些用户发起。 - -SpringSecurity为我们提供了两种授权方式: - -* 基于权限的授权:只要拥有某权限的用户,就可以访问某个路径 -* 基于角色的授权:根据用户属于哪个角色来决定是否可以访问某个路径 - -两者只是概念上的不同,实际上使用起来效果差不多。这里我们就先演示以角色方式来进行授权。 - -### 基于角色的授权 - -现在我们希望创建两个角色,普通用户和管理员,普通用户只能访问index页面,而管理员可以访问任何页面。 - -首先我们需要对数据库中的角色表进行一些修改,添加一个用户角色字段,并创建一个新的用户,Test用户的角色为user,而Admin用户的角色为admin。 - -接着我们需要配置SpringSecurity,决定哪些角色可以访问哪些页面: - -```java -http - .authorizeRequests() - .antMatchers("/static/**").permitAll() - .antMatchers("/index").hasAnyRole("user", "admin") //index页面可以由user或admin访问 - .anyRequest().hasRole("admin") //除了上面以外的所有内容,只能是admin访问 -``` - -接着我们需要稍微修改一下验证逻辑,首先创建一个实体类用于表示数据库中的用户名、密码和角色: - -```java -@Data -public class AuthUser { - String username; - String password; - String role; -} -``` - -接着修改一下Mapper: - -```java -@Mapper -public interface UserMapper { - - @Select("select * from users where username = #{username}") - AuthUser getPasswordByUsername(String username); -} -``` - -最后再修改一下Service: - -```java -@Override -public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException { - AuthUser user = mapper.getPasswordByUsername(s); - if(user == null) - throw new UsernameNotFoundException("登录失败,用户名或密码错误!"); - return User - .withUsername(user.getUsername()) - .password(user.getPassword()) - .roles(user.getRole()) - .build(); -} -``` - -现在我们可以尝试登陆,接着访问一下`/index`和`/admin`两个页面。 - -### 基于权限的授权 - -基于权限的授权与角色类似,需要以`hasAnyAuthority`或`hasAuthority`进行判断: - -```java -.anyRequest().hasAnyAuthority("page:index") -``` - -```java -@Override -public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException { - AuthUser user = mapper.getPasswordByUsername(s); - if(user == null) - throw new UsernameNotFoundException("登录失败,用户名或密码错误!"); - return User - .withUsername(user.getUsername()) - .password(user.getPassword()) - .authorities("page:index") - .build(); -} -``` - -### 使用注解判断权限 - -除了直接配置以外,我们还可以以注解形式直接配置,首先需要在配置类(注意这里是在Mvc的配置类上添加,因为这里只针对Controller进行过滤,所有的Controller是由Mvc配置类进行注册的,如果需要为Service或其他Bean也启用权限判断,则需要在Security的配置类上添加)上开启: - -```java -@EnableGlobalMethodSecurity(prePostEnabled = true) -public class SecurityConfiguration extends WebSecurityConfigurerAdapter { -``` - -接着我们可以直接在需要添加权限验证的请求映射上添加注解: - -```java -@PreAuthorize("hasRole('user')") //判断是否为user角色,只有此角色才可以访问 -@RequestMapping("/index") -public String index(){ - return "index"; -} -``` - -通过添加`@PreAuthorize`注解,在执行之前判断判断权限,如果没有对应的权限或是对应的角色,将无法访问页面。 - -这里其实是使用了SpEL表达式,相当于可以执行一些逻辑再得到结果,而不是直接传值,官方文档地址:https://docs.spring.io/spring-framework/docs/5.2.13.RELEASE/spring-framework-reference/core.html#expressions,内容比较多,不是重点,这里就不再详细介绍了。 - -同样的还有`@PostAuthorize`注解,但是它是在方法执行之后再进行拦截: - -```java -@PostAuthorize("hasRole('user')") -@RequestMapping("/index") -public String index(){ - System.out.println("执行了"); - return "index"; -} -``` - -除了Controller以外,只要是由Spring管理的Bean都可以使用注解形式来控制权限,只要不具备访问权限,那么就无法执行方法并且会返回403页面。 - -```java -@Service -public class UserService { - - @PreAuthorize("hasAnyRole('user')") - public void test(){ - System.out.println("成功执行"); - } -} -``` - -注意Service是由根容器进行注册,需要在Security配置类上添加`@EnableGlobalMethodSecurity`注解才可以生效。与具有相同功能的还有`@Secure`但是它不支持SpEL表达式的权限表示形式,并且需要添加"ROLE_"前缀,这里就不做演示了。 - -我们还可以使用`@PreFilter`和`@PostFilter`对集合类型的参数或返回值进行过滤。 - -比如: - -```java -@PreFilter("filterObject.equals('lbwnb')") //filterObject代表集合中每个元素,只要满足条件的元素才会留下 -public void test(List list){ - System.out.println("成功执行"+list); -} -``` - -```java -@RequestMapping("/index") -public String index(){ - List list = new LinkedList<>(); - list.add("lbwnb"); - list.add("yyds"); - service.test(list); - return "index"; -} -``` - -与`@PreFilter`类似的`@PostFilter`这里就不做演示了,它用于处理返回值,使用方法是一样的。 - -当有多个集合时,需要使用`filterTarget`进行指定: - -```java -@PreFilter(value = "filterObject.equals('lbwnb')", filterTarget = "list2") -public void test(List list, List list2){ - System.out.println("成功执行"+list); -} -``` - -*** - -## 记住我 - -我们的网站还有一个重要的功能,就是记住我,也就是说我们可以在登陆之后的一段时间内,无需再次输入账号和密码进行登陆,相当于服务端已经记住当前用户,再次访问时就可以免登陆进入,这是一个非常常用的功能。 - -我们之前在JavaWeb阶段,使用本地Cookie存储的方式实现了记住我功能,但是这种方式并不安全,同时在代码编写上也比较麻烦,那么能否有一种更加高效的记住我功能实现呢? - -SpringSecurity为我们提供了一种优秀的实现,它为每个已经登陆的浏览器分配一个携带Token的Cookie,并且此Cookie默认会被保留14天,只要我们不清理浏览器的Cookie,那么下次携带此Cookie访问服务器将无需登陆,直接继续使用之前登陆的身份,这样显然比我们之前的写法更加简便。并且我们需要进行简单配置,即可开启记住我功能: - -```java -.and() -.rememberMe() //开启记住我功能 -.rememberMeParameter("remember") //登陆请求表单中需要携带的参数,如果携带,那么本次登陆会被记住 -.tokenRepository(new InMemoryTokenRepositoryImpl()) //这里使用的是直接在内存中保存的TokenRepository实现 - //TokenRepository有很多种实现,InMemoryTokenRepositoryImpl直接基于Map实现的,缺点就是占内存、服务器重启后记住我功能将失效 - //后面我们还会讲解如何使用数据库来持久化保存Token信息 -``` - -接着我们需要在前端修改一下记住我勾选框的名称,将名称修改与上面一致,如果上面没有配置名称,那么默认使用"remember-me"作为名称: - -```html - -``` - -现在我们启动服务器,在登陆时勾选记住我勾选框,观察Cookie的变化。 - -虽然现在已经可以实现记住我功能了,但是还有一定的缺陷,如果服务器重新启动(因为Token信息全部存在HashMap中,也就是存在内存中),那么所有记录的Token信息将全部丢失,这时即使浏览器携带了之前的Token也无法恢复之前登陆的身份。 - -我们可以将Token信息记录全部存放到数据库中(学习了Redis之后还可以放到Redis服务器中)利用数据库的持久化存储机制,即使服务器重新启动,所有的Token信息也不会丢失,配置数据库存储也很简单: - -```java -@Resource -PersistentTokenRepository repository; - -@Bean -public PersistentTokenRepository jdbcRepository(@Autowired DataSource dataSource){ - JdbcTokenRepositoryImpl repository = new JdbcTokenRepositoryImpl(); //使用基于JDBC的实现 - repository.setDataSource(dataSource); //配置数据源 - repository.setCreateTableOnStartup(true); //启动时自动创建用于存储Token的表(建议第一次启动之后删除该行) - return repository; -} -``` - -```java -.and() -.rememberMe() -.rememberMeParameter("remember") -.tokenRepository(repository) -.tokenValiditySeconds(60 * 60 * 24 * 7) //Token的有效时间(秒)默认为14天 -``` - -稍后服务器启动我们可以观察一下数据库,如果出现名为`persistent_logins`的表,那么证明配置没有问题。 - -当我们登陆并勾选了记住我之后,那么数据库中会新增一条Token记录。 - -*** - -## SecurityContext - -用户登录之后,怎么获取当前已经登录用户的信息呢?通过使用SecurityContextHolder就可以很方便地得到SecurityContext对象了,我们可以直接使用SecurityContext对象来获取当前的认证信息: - -```java -@RequestMapping("/index") - public String index(){ - SecurityContext context = SecurityContextHolder.getContext(); - Authentication authentication = context.getAuthentication(); - User user = (User) authentication.getPrincipal(); - System.out.println(user.getUsername()); - System.out.println(user.getAuthorities()); - return "index"; - } -``` - -通过SecurityContext我们就可以快速获取当前用户的名称和授权信息等。 - -除了这种方式以外,我们还可以直接从Session中获取: - -```java -@RequestMapping("/index") -public String index(@SessionAttribute("SPRING_SECURITY_CONTEXT") SecurityContext context){ - Authentication authentication = context.getAuthentication(); - User user = (User) authentication.getPrincipal(); - System.out.println(user.getUsername()); - System.out.println(user.getAuthorities()); - return "index"; -} -``` - -注意SecurityContextHolder是有一定的存储策略的,SecurityContextHolder中的SecurityContext对象会在一开始请求到来时被设定,至于存储方式其实是由存储策略决定的,如果我们这样编写,那么在默认情况下是无法获取到认证信息的: - -```java -@RequestMapping("/index") -public String index(){ - new Thread(() -> { //创建一个子线程去获取 - SecurityContext context = SecurityContextHolder.getContext(); - Authentication authentication = context.getAuthentication(); - User user = (User) authentication.getPrincipal(); //NPE - System.out.println(user.getUsername()); - System.out.println(user.getAuthorities()); - }); - return "index"; -} -``` - -这是因为SecurityContextHolder的存储策略默认是`MODE_THREADLOCAL`,它是基于ThreadLocal实现的,`getContext()`方法本质上调用的是对应的存储策略实现的方法: - -```java -public static SecurityContext getContext() { - return strategy.getContext(); -} -``` - -SecurityContextHolderStrategy有三个实现类: - -* GlobalSecurityContextHolderStrategy:全局模式,不常用 -* ThreadLocalSecurityContextHolderStrategy:基于ThreadLocal实现,线程内可见 -* InheritableThreadLocalSecurityContextHolderStrategy:基于InheritableThreadLocal实现,线程和子线程可见 - -因此,如果上述情况需要在子线程中获取,那么需要修改SecurityContextHolder的存储策略,在初始化的时候设置: - -```java -@PostConstruct -public void init(){ - SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL); -} -``` - -这样在子线程中也可以获取认证信息了。 - -因为用户的验证信息是基于SecurityContext进行判断的,我们可以直接修改SecurityContext的内容,来手动为用户进行登陆: - -```java -@RequestMapping("/auth") -@ResponseBody -public String auth(){ - SecurityContext context = SecurityContextHolder.getContext(); //获取SecurityContext对象(当前会话肯定是没有登陆的) - UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken("Test", null, - AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_user")); //手动创建一个UsernamePasswordAuthenticationToken对象,也就是用户的认证信息,角色需要添加ROLE_前缀,权限直接写 - context.setAuthentication(token); //手动为SecurityContext设定认证信息 - return "Login success!"; -} -``` - -在未登陆的情况下,访问此地址将直接进行手动登陆,再次访问`/index`页面,可以直接访问,说明手动设置认证信息成功。 - -**疑惑:**SecurityContext这玩意不是默认线程独占吗,那每次请求都是一个新的线程,按理说上一次的SecurityContext对象应该没了才对啊,为什么再次请求依然能够继续使用上一次SecurityContext中的认证信息呢? - -SecurityContext的生命周期:请求到来时从Session中取出,放入SecurityContextHolder中,请求结束时从SecurityContextHolder取出,并放到Session中,实际上就是依靠Session来存储的,一旦会话过期验证信息也跟着消失。 - -*** - -## SpringSecurity原理 - -**注意:**本小节内容作为选学内容,但是难度比前两章的源码部分简单得多。 - -最后我们再来聊一下SpringSecurity的实现原理,它本质上是依靠N个Filter实现的,也就是一个完整的过滤链(注意这里是过滤器,不是拦截器) - -我们就从`AbstractSecurityWebApplicationInitializer`开始下手,我们来看看它配置了什么: - -```java -//此方法会在启动时被调用 -public final void onStartup(ServletContext servletContext) { - this.beforeSpringSecurityFilterChain(servletContext); - if (this.configurationClasses != null) { - AnnotationConfigWebApplicationContext rootAppContext = new AnnotationConfigWebApplicationContext(); - rootAppContext.register(this.configurationClasses); - servletContext.addListener(new ContextLoaderListener(rootAppContext)); - } - - if (this.enableHttpSessionEventPublisher()) { - servletContext.addListener("org.springframework.security.web.session.HttpSessionEventPublisher"); - } - - servletContext.setSessionTrackingModes(this.getSessionTrackingModes()); - //重点在这里,这里插入了关键的FilterChain - this.insertSpringSecurityFilterChain(servletContext); - this.afterSpringSecurityFilterChain(servletContext); -} -``` - -```java -private void insertSpringSecurityFilterChain(ServletContext servletContext) { - String filterName = "springSecurityFilterChain"; - //创建了一个DelegatingFilterProxy对象,它本质上也是一个Filter - DelegatingFilterProxy springSecurityFilterChain = new DelegatingFilterProxy(filterName); - String contextAttribute = this.getWebApplicationContextAttribute(); - if (contextAttribute != null) { - springSecurityFilterChain.setContextAttribute(contextAttribute); - } - //通过ServletContext注册DelegatingFilterProxy这个Filter - this.registerFilter(servletContext, true, filterName, springSecurityFilterChain); -} -``` - -我们接着来看看,`DelegatingFilterProxy`在做什么: - -```java -//这个是初始化方法,它由GenericFilterBean(父类)定义,在afterPropertiesSet方法中被调用 -protected void initFilterBean() throws ServletException { - synchronized(this.delegateMonitor) { - if (this.delegate == null) { - if (this.targetBeanName == null) { - this.targetBeanName = this.getFilterName(); - } - - WebApplicationContext wac = this.findWebApplicationContext(); - if (wac != null) { - //耐心点,套娃很正常 - this.delegate = this.initDelegate(wac); - } - } - - } -} -``` - -```java -protected Filter initDelegate(WebApplicationContext wac) throws ServletException { - String targetBeanName = this.getTargetBeanName(); - Assert.state(targetBeanName != null, "No target bean name set"); - //这里通过WebApplicationContext获取了一个Bean - Filter delegate = (Filter)wac.getBean(targetBeanName, Filter.class); - if (this.isTargetFilterLifecycle()) { - delegate.init(this.getFilterConfig()); - } - - //返回Filter - return delegate; -} -``` - -这里我们需要添加一个断点来查看到底获取到了什么Bean。 - -通过断点调试,我们发现这里放回的对象是一个FilterChainProxy类型的,并且调用了它的初始化方法,但是FilterChainProxy类中并没有重写`init`方法或是`initFilterBean`方法。 - -我们倒回去看,当Filter返回之后,`DelegatingFilterProxy`的一个成员变量`delegate`被赋值为得到的Filter,也就是FilterChainProxy对象,接着我们来看看,`DelegatingFilterProxy`是如何执行doFilter方法的。 - -```java -public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException { - Filter delegateToUse = this.delegate; - if (delegateToUse == null) { - //非正常情况,这里省略... - } - //这里才是真正的调用,别忘了delegateToUse就是初始化的FilterChainProxy对象 - this.invokeDelegate(delegateToUse, request, response, filterChain); -} -``` - -```java -protected void invokeDelegate(Filter delegate, ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException { - //最后实际上调用的是FilterChainProxy的doFilter方法 - delegate.doFilter(request, response, filterChain); -} -``` - -所以我们接着来看,`FilterChainProxy`的doFilter方法又在干什么: - -```java -public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - boolean clearContext = request.getAttribute(FILTER_APPLIED) == null; - if (!clearContext) { - //真正的过滤在这里执行 - this.doFilterInternal(request, response, chain); - } else { - //... - } -} -``` - -```java -private void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - FirewalledRequest firewallRequest = this.firewall.getFirewalledRequest((HttpServletRequest)request); - HttpServletResponse firewallResponse = this.firewall.getFirewalledResponse((HttpServletResponse)response); - //这里获取了一个Filter列表,实际上SpringSecurity就是由N个过滤器实现的,这里获取的都是SpringSecurity提供的过滤器 - //但是请注意,经过我们之前的分析,实际上真正注册的Filter只有DelegatingFilterProxy - //而这里的Filter列表中的所有Filter并没有被注册,而是在这里进行内部调用 - List filters = this.getFilters((HttpServletRequest)firewallRequest); - //只要Filter列表不是空,就依次执行内置的Filter - if (filters != null && filters.size() != 0) { - if (logger.isDebugEnabled()) { - logger.debug(LogMessage.of(() -> { - return "Securing " + requestLine(firewallRequest); - })); - } - //这里创建一个虚拟的过滤链,过滤流程是由SpringSecurity自己实现的 - FilterChainProxy.VirtualFilterChain virtualFilterChain = new FilterChainProxy.VirtualFilterChain(firewallRequest, chain, filters); - //调用虚拟过滤链的doFilter - virtualFilterChain.doFilter(firewallRequest, firewallResponse); - } else { - if (logger.isTraceEnabled()) { - logger.trace(LogMessage.of(() -> { - return "No security for " + requestLine(firewallRequest); - })); - } - - firewallRequest.reset(); - chain.doFilter(firewallRequest, firewallResponse); - } -} -``` - -我们来看一下虚拟过滤链的doFilter是怎么处理的: - -```java -//看似没有任何循环,实际上就是一个循环,是一个递归调用 -public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException { - //判断是否已经通过全部的内置过滤器,定位是否等于当前大小 - if (this.currentPosition == this.size) { - if (FilterChainProxy.logger.isDebugEnabled()) { - FilterChainProxy.logger.debug(LogMessage.of(() -> { - return "Secured " + FilterChainProxy.requestLine(this.firewalledRequest); - })); - } - - this.firewalledRequest.reset(); - //所有的内置过滤器已经完成,按照正常流程走DelegatingFilterProxy的下一个Filter - //也就是说这里之后就与DelegatingFilterProxy没有任何关系了,该走其他过滤器就走其他地方配置的过滤器,SpringSecurity的过滤操作已经结束 - this.originalChain.doFilter(request, response); - } else { - //定位自增 - ++this.currentPosition; - //获取当前定位的Filter - Filter nextFilter = (Filter)this.additionalFilters.get(this.currentPosition - 1); - if (FilterChainProxy.logger.isTraceEnabled()) { - FilterChainProxy.logger.trace(LogMessage.format("Invoking %s (%d/%d)", nextFilter.getClass().getSimpleName(), this.currentPosition, this.size)); - } - //执行内部过滤器的doFilter方法,传入当前对象本身作为Filter,执行如果成功,那么一定会再次调用当前对象的doFilter方法 - //可能最不理解的就是这里,执行的难道不是内部其他Filter的doFilter方法吗,怎么会让当前对象的doFilter方法递归调用呢? - //没关系,了解了其中一个内部过滤器就明白了 - nextFilter.doFilter(request, response, this); - } -} -``` - -因此,我们差不多已经了解了整个SpringSecurity的实现机制了,那么我们来看几个内部的过滤器分别在做什么。 - -比如用于处理登陆的过滤器`UsernamePasswordAuthenticationFilter`,它继承自`AbstractAuthenticationProcessingFilter`,我们来看看它是怎么进行过滤的: - -```java -public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - this.doFilter((HttpServletRequest)request, (HttpServletResponse)response, chain); -} - -private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { - //如果不是登陆请求,那么根本不会理这个请求 - if (!this.requiresAuthentication(request, response)) { - //直接调用传入的FilterChain的doFilter方法 - //而这里传入的正好是VirtualFilterChain对象 - //这下知道为什么上面说是递归了吧 - chain.doFilter(request, response); - } else { - //如果是登陆请求,那么会执行登陆请求的相关逻辑,注意执行过程中出现任何问题都会抛出异常 - //比如用户名和密码错误,我们之前也已经测试过了,会得到一个BadCredentialsException - try { - //进行认证 - Authentication authenticationResult = this.attemptAuthentication(request, response); - if (authenticationResult == null) { - return; - } - - this.sessionStrategy.onAuthentication(authenticationResult, request, response); - if (this.continueChainBeforeSuccessfulAuthentication) { - chain.doFilter(request, response); - } - - //如果一路绿灯,没有报错,那么验证成功,执行successfulAuthentication - this.successfulAuthentication(request, response, chain, authenticationResult); - } catch (InternalAuthenticationServiceException var5) { - this.logger.error("An internal error occurred while trying to authenticate the user.", var5); - //验证失败,会执行unsuccessfulAuthentication - this.unsuccessfulAuthentication(request, response, var5); - } catch (AuthenticationException var6) { - this.unsuccessfulAuthentication(request, response, var6); - } - - } -} -``` - -那么我们来看看successfulAuthentication和unsuccessfulAuthentication分别做了什么: - -```java -protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { - //向SecurityContextHolder添加认证信息,我们可以通过SecurityContextHolder对象获取当前登陆的用户 - SecurityContextHolder.getContext().setAuthentication(authResult); - if (this.logger.isDebugEnabled()) { - this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult)); - } - - //记住我实现 - this.rememberMeServices.loginSuccess(request, response, authResult); - if (this.eventPublisher != null) { - this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass())); - } - - //调用默认的或是我们自己定义的AuthenticationSuccessHandler的onAuthenticationSuccess方法 - //这个根据我们配置文件决定 - //到这里其实页面就已经直接跳转了 - this.successHandler.onAuthenticationSuccess(request, response, authResult); -} - -protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException { - //登陆失败会直接清理掉SecurityContextHolder中的认证信息 - SecurityContextHolder.clearContext(); - this.logger.trace("Failed to process authentication request", failed); - this.logger.trace("Cleared SecurityContextHolder"); - this.logger.trace("Handling authentication failure"); - //登陆失败的记住我处理 - this.rememberMeServices.loginFail(request, response); - //同上,调用默认或是我们自己定义的AuthenticationFailureHandler - this.failureHandler.onAuthenticationFailure(request, response, failed); -} -``` - -了解了整个用户验证实现流程,其实其它的过滤器是如何实现的也就很容易联想到了,SpringSecurity的过滤器从某种意义上来说,更像是一个处理业务的Servlet,它做的事情不像是拦截,更像是完成自己对应的职责,只不过是使用了过滤器机制进行实现罢了。 - -SecurityContextPersistenceFilter也是内置的Filter,可以尝试阅读一下其源码,了解整个SecurityContextHolder的运作原理,这里先说一下大致流程,各位可以依照整个流程按照源码进行推导: - -当过滤器链执行到SecurityContextPersistenceFilter时,它会从HttpSession中把SecurityContext对象取出来(是存在Session中的,跟随会话的消失而消失),然后放入SecurityContextHolder对象中。请求结束后,再把SecurityContext存入HttpSession中,并清除SecurityContextHolder内的SecurityContext对象。 - -*** - -## 完善功能 - -在了解了SpringSecurity的大部分功能后,我们就来将整个网站的内容进行完善,登陆目前已经实现了,我们还需要实现以下功能: - -* 注册功能(仅针对于学生) -* 角色分为同学和管理员 - * 管理员负责上架、删除、更新书籍,查看所有同学的借阅列表 - * 同学可以借阅和归还书籍,以及查看自己的借阅列表 - -开始之前我们需要先配置一下Thymeleaf的SpringSecurity扩展,它针对SpringSecurity提供了更多额外的解析: - -```xml - - org.thymeleaf.extras - thymeleaf-extras-springsecurity5 - 3.0.4.RELEASE - -``` - -```java -//配置模板引擎Bean -@Bean -public SpringTemplateEngine springTemplateEngine(@Autowired ITemplateResolver resolver){ - SpringTemplateEngine engine = new SpringTemplateEngine(); - engine.setTemplateResolver(resolver); - engine.addDialect(new SpringSecurityDialect()); //添加针对于SpringSecurity的方言 - return engine; -} -``` - -```html - -``` - -下一章就是最后一章了,我们会深入讲解MySQL的高级部分,包括函数、存储过程、锁机制、索引以及存储引擎。 diff --git a/青空笔记/JavaSSM笔记/JavaSSM笔记(二).md b/青空笔记/JavaSSM笔记/JavaSSM笔记(二).md deleted file mode 100644 index 4ad9031..0000000 --- a/青空笔记/JavaSSM笔记/JavaSSM笔记(二).md +++ /dev/null @@ -1,1470 +0,0 @@ - - - - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fpic4.zhimg.com%2Fv2-37ff43e92267558c2fbaa70aedbfc133_1200x500.jpg&refer=http%3A%2F%2Fpic4.zhimg.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1641702986&t=9a8080a4683d1e65b0b5e6f1ac764dfe) - -# SpringMVC - -在前面学习完Spring框架技术之后,差不多会出现两批人:一批是听得云里雾里,依然不明白这个东西是干嘛的;还有一批就是差不多理解了核心思想,但是不知道这些东西该如何去发挥它的作用。在SpringMVC阶段,你就能逐渐够体会到Spring框架为我们带来的便捷之处了。 - -此阶段,我们将再次回到Tomcat的Web应用程序开发中,去感受SpringMVC为我们带来的巨大便捷。 - -## MVC理论基础 - -在之前,我们给大家讲解了三层架构,包括: - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fs4.51cto.com%2Fwyfs02%2FM00%2F8B%2FBA%2FwKioL1hXU8vRX8elAAA2bXqAxMs799.png&refer=http%3A%2F%2Fs4.51cto.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1641788288&t=1658084627505c12812596d8ca1b9885) - -每一层都有着各自的职责,其中最关键的当属表示层,因为它相当于就是直接与用户的浏览器打交道的一层,并且所有的请求都会经过它进行解析,然后再告知业务层进行处理,任何页面的返回和数据填充也全靠表示层来完成,因此它实际上是整个三层架构中最关键的一层,而在之前的实战开发中,我们编写了大量的Servlet(也就是表示层实现)来处理来自浏览器的各种请求,但是我们发现,仅仅是几个很小的功能,以及几个很基本的页面,我们都要编写将近十个Servlet,如果是更加大型的网站系统,比如淘宝、B站,光是一个页面中可能就包含了几十甚至上百个功能,想想那样的话写起来得多恐怖。 - -因此,SpringMVC正是为了解决这种问题而生的,它是一个非常优秀的表示层框架(在此之前还有一个叫做Struts2的框架,但是现阶段貌似快凉透了),采用MVC思想设计实现。 - -MVC解释如下: - -* M是指业务模型(Model):通俗的讲就是我们之前用于封装数据传递的实体类。 -* V是指用户界面(View):一般指的是前端页面。 -* C则是控制器(Controller):控制器就相当于Servlet的基本功能,处理请求,返回响应。 - -![点击查看源网页](https://pics5.baidu.com/feed/d0c8a786c9177f3e5707320277aeb1c39f3d5677.jpeg?token=e4c298063f4efa11fd0f94c2760c252b&s=782834721BC044435C55F4CA0000E0B1) - -SpringMVC正是希望这三者之间进行解耦,实现各干各的,更加精细地划分对应的职责。最后再将View和Model进行渲染,得到最终的页面并返回给前端(就像之前使用Thymeleaf那样,把实体数据对象和前端页面都给到Thymeleaf,然后它会将其进行整合渲染得到最终有数据的页面,而本教程也会使用Thymeleaf作为视图解析器进行讲解) - -*** - -## 配置环境并搭建项目 - -由于SpringMVC还没有支持最新的Tomcat10(主要是之前提到的包名问题,神仙打架百姓遭殃)所以我们干脆就再来配置一下Tomcat9环境,相当于回顾一下。 - -下载地址:https://tomcat.apache.org/download-90.cgi - -添加SpringMVC的依赖: - -```xml - - org.springframework - spring-webmvc - 5.3.13 - -``` - -接着我们需要配置一下web.xml,将DispatcherServlet替换掉Tomcat自带的Servlet,这里url-pattern需要写为`/`,即可完成替换: - -```xml - - - - mvc - org.springframework.web.servlet.DispatcherServlet - - - mvc - / - - -``` - -接着需要为整个Web应用程序配置一个Spring上下文环境(也就是容器),因为SpringMVC是基于Spring开发的,它直接利用Spring提供的容器来实现各种功能,这里我们直接使用注解方式进行配置,不再使用XML配置文件: - -```xml - - contextConfigLocation - com.example.config.MvcConfiguration - - - contextClass - org.springframework.web.context.support.AnnotationConfigWebApplicationContext - -``` - -如果还是想使用XML配置文件进行配置,那么可以直接这样写: - -```xml - - contextConfigLocation - 配置文件名称 - -``` - -如果你希望完完全全丢弃配置文件,可以直接添加一个类,Tomcat会在类路径中查找实现ServletContainerInitializer 接口的类,如果发现的话,就用它来配置Servlet容器,Spring提供了这个接口的实现类 SpringServletContainerInitializer , 通过@HandlesTypes(WebApplicationInitializer.class)设置,这个类反过来会查找实现WebApplicationInitializer 的类,并将配置的任务交给他们来完成,因此直接实现接口即可: - -```java -public class MainInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { - @Override - protected Class[] getRootConfigClasses() { - return new Class[]{MainConfiguration.class}; //基本的Spring配置类,一般用于业务层配置 - } - - @Override - protected Class[] getServletConfigClasses() { - return new Class[0]; //配置DispatcherServlet的配置类、主要用于Controller等配置 - } - - @Override - protected String[] getServletMappings() { - return new String[]{"/"}; //匹配路径,与上面一致 - } -} -``` - -顺便编写一下最基本的配置类: - -```java -@Configuration -public class MainConfiguration { - -} -``` - -后面我们都采用无XML配置方式进行讲解。 - -![img](https://img2018.cnblogs.com/blog/738818/201906/738818-20190617214214614-761905677.png) - -这样,就完成最基本的配置了,现在任何请求都会优先经过`DispatcherServlet`进行集中处理,下面我们会详细讲解如何使用它。 - -*** - -## Controller控制器 - -有了SpringMVC之后,我们不必再像之前那样一个请求地址创建一个Servlet了,它使用`DispatcherServlet`替代Tomcat为我们提供的默认的静态资源Servlet,也就是说,现在所有的请求(除了jsp,因为Tomcat还提供了一个jsp的Servlet)都会经过`DispatcherServlet`进行处理。 - -那么`DispatcherServlet`会帮助我们做什么呢? - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg2018.cnblogs.com%2Fblog%2F1190675%2F201812%2F1190675-20181203121258033-524477408.png&refer=http%3A%2F%2Fimg2018.cnblogs.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1642058685&t=13fd16796a8b5ed58762c9947f15681f) - -根据图片我们可以了解,我们的请求到达Tomcat服务器之后,会交给当前的Web应用程序进行处理,而SpringMVC使用`DispatcherServlet`来处理所有的请求,也就是说它被作为一个统一的访问点,所有的请求全部由它来进行调度。 - -当一个请求经过`DispatcherServlet`之后,会先走`HandlerMapping`,它会将请求映射为`HandlerExecutionChain`,依次经过`HandlerInterceptor`有点类似于之前我们所学的过滤器,不过在SpringMVC中我们使用的是拦截器,然后再交给`HandlerAdapter`,根据请求的路径选择合适的控制器进行处理,控制器处理完成之后,会返回一个`ModelAndView`对象,包括数据模型和视图,通俗的讲就是页面中数据和页面本身(只包含视图名称即可)。 - -返回`ModelAndView`之后,会交给`ViewResolver`(视图解析器)进行处理,视图解析器会对整个视图页面进行解析,SpringMVC自带了一些视图解析器,但是只适用于JSP页面,我们也可以像之前一样使用Thymeleaf作为视图解析器,这样我们就可以根据给定的视图名称,直接读取HTML编写的页面,解析为一个真正的View。 - -解析完成后,就需要将页面中的数据全部渲染到View中,最后返回给`DispatcherServlet`一个包含所有数据的成形页面,再响应给浏览器,完成整个过程。 - -因此,实际上整个过程我们只需要编写对应请求路径的的Controller以及配置好我们需要的ViewResolver即可,之后还可以继续补充添加拦截器,而其他的流程已经由SpringMVC帮助我们完成了。 - -### 配置视图解析器和控制器 - -首先我们需要实现最基本的页面解析并返回,第一步就是配置视图解析器,这里我们使用Thymeleaf为我们提供的视图解析器,导入需要的依赖: - -```xml - - org.thymeleaf - thymeleaf-spring5 - 3.0.12.RELEASE - -``` - -配置视图解析器非常简单,我们只需要将对应的`ViewResolver`注册为Bean即可,这里我们直接在配置类中编写: - -```java -@ComponentScan("com.example.controller") -@Configuration -@EnableWebMvc -public class WebConfiguration { - - //我们需要使用ThymeleafViewResolver作为视图解析器,并解析我们的HTML页面 - @Bean - public ThymeleafViewResolver thymeleafViewResolver(@Autowired SpringTemplateEngine springTemplateEngine){ - ThymeleafViewResolver resolver = new ThymeleafViewResolver(); - resolver.setOrder(1); //可以存在多个视图解析器,并且可以为他们设定解析顺序 - resolver.setCharacterEncoding("UTF-8"); //编码格式是重中之重 - resolver.setTemplateEngine(springTemplateEngine); //和之前JavaWeb阶段一样,需要使用模板引擎进行解析,所以这里也需要设定一下模板引擎 - return resolver; - } - - //配置模板解析器 - @Bean - public SpringResourceTemplateResolver templateResolver(){ - SpringResourceTemplateResolver resolver = new SpringResourceTemplateResolver(); - resolver.setSuffix(".html"); //需要解析的后缀名称 - resolver.setPrefix("/"); //需要解析的HTML页面文件存放的位置 - return resolver; - } - - //配置模板引擎Bean - @Bean - public SpringTemplateEngine springTemplateEngine(@Autowired ITemplateResolver resolver){ - SpringTemplateEngine engine = new SpringTemplateEngine(); - engine.setTemplateResolver(resolver); //模板解析器,默认即可 - return engine; - } -} -``` - -别忘了在`Initializer`中添加此类作为配置: - -```java -@Override -protected Class[] getServletConfigClasses() { - return new Class[]{MvcConfiguration.class}; -} -``` - -现在我们就完成了视图解析器的配置,我们接着来创建一个Controller,创建Controller也非常简单,只需在一个类上添加一个`@Controller`注解即可,它会被Spring扫描并自动注册为Controller类型的Bean,然后我们只需要在类中编写方法用于处理对应地址的请求即可: - -```java -@Controller //直接添加注解即可 -public class MainController { - - @RequestMapping("/index") //直接填写访问路径 - public ModelAndView index(){ - return new ModelAndView("index"); //返回ModelAndView对象,这里填入了视图的名称 - //返回后会经过视图解析器进行处理 - } -} -``` - -我们会发现,打开浏览器之后就可以直接访问我们的HTML页面了。 - -而页面中的数据我们可以直接向Model进行提供: - -```java -@RequestMapping(value = "/index") -public ModelAndView index(){ - ModelAndView modelAndView = new ModelAndView("index"); - modelAndView.getModel().put("name", "啊这"); - return modelAndView; -} -``` - -这样Thymeleaf就能收到我们传递的数据进行解析: - -```html - - - - - Title - - - - HelloWorld! -
- - -``` - -当然,如果仅仅是传递一个页面不需要任何的附加属性,我们可以直接返回View名称,SpringMVC会将其自动包装为ModelAndView对象: - -```java -@RequestMapping(value = "/index") -public String index(){ - return "index"; -} -``` - -还可以单独添加一个Model作为形参进行设置,SpringMVC会自动帮助我们传递实例对象: - -```java -@RequestMapping(value = "/index") -public String index(Model model){ //这里不仅仅可以是Model,还可以是Map、ModelMap - model.addAttribute("name", "yyds"); - return "index"; -} -``` - -这么方便的写法,你就说你爱不爱吧,你爱不爱。 - -注意,一定要保证视图名称下面出现横线并且按住Ctrl可以跳转,配置才是正确的(最新版IDEA) - -我们的页面中可能还会包含一些静态资源,比如js、css,因此这里我们还需要配置一下,让静态资源通过Tomcat提供的默认Servlet进行解析,我们需要让配置类实现一下`WebMvcConfigurer`接口,这样在Web应用程序启动时,会根据我们重写方法里面的内容进行进一步的配置: - -```java -@Override -public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) { - configurer.enable(); //开启默认的Servlet -} - -@Override -public void addResourceHandlers(ResourceHandlerRegistry registry) { - registry.addResourceHandler("/static/**").addResourceLocations("/WEB-INF/static/"); - //配置静态资源的访问路径 -} -``` - -我们编写一下前端内容: - -```xml - - - - - Title - - - - - HelloWorld! - - -``` - -创建`test.js`并编写如下内容: - -```js -window.alert("欢迎来到GayHub全球最大同性交友网站") -``` - -最后访问页面,页面在加载时就会显示一个弹窗,这样我们就完成了最基本的页面配置。相比之前的方式,这样就简单很多了,直接避免了编写大量的Servlet来处理请求。 - -### @RequestMapping详解 - -前面我们已经了解了如何创建一个控制器来处理我们的请求,接着我们只需要在控制器添加一个方法用于处理对应的请求即可,之前我们需要完整地编写一个Servlet来实现,而现在我们只需要添加一个`@RequestMapping`即可实现,其实从它的名字我们也能得知,此注解就是将请求和处理请求的方法建立一个映射关系,当收到请求时就可以根据映射关系调用对应的请求处理方法,那么我们就来先聊聊`@RequestMapping`吧,注解定义如下: - -```java -@Mapping -public @interface RequestMapping { - String name() default ""; - - @AliasFor("path") - String[] value() default {}; - - @AliasFor("value") - String[] path() default {}; - - RequestMethod[] method() default {}; - - String[] params() default {}; - - String[] headers() default {}; - - String[] consumes() default {}; - - String[] produces() default {}; -} -``` - -其中最关键的是path属性(等价于value),它决定了当前方法处理的请求路径,注意路径必须全局唯一,任何路径只能有一个方法进行处理,它是一个数组,也就是说此方法不仅仅可以只用于处理某一个请求路径,我们可以使用此方法处理多个请求路径: - -```java -@RequestMapping({"/index", "/test"}) -public ModelAndView index(){ - return new ModelAndView("index"); -} -``` - -现在我们访问/index或是/test都会经过此方法进行处理。 - -我们也可以直接将`@RequestMapping`添加到类名上,表示为此类中的所有请求映射添加一个路径前缀,比如: - -```java -@Controller -@RequestMapping("/yyds") -public class MainController { - - @RequestMapping({"/index", "/test"}) - public ModelAndView index(){ - return new ModelAndView("index"); - } -} -``` - -那么现在我们需要访问`/yyds/index`或是`/yyds/test`才可以得到此页面。我们可以直接在IDEA下方的端点板块中查看当前Web应用程序定义的所有请求映射,并且可以通过IDEA为我们提供的内置Web客户端直接访问某个路径。 - -路径还支持使用通配符进行匹配: - -* ?:表示任意一个字符,比如`@RequestMapping("/index/x?")`可以匹配/index/xa、/index/xb等等。 -* *:表示任意0-n个字符,比如`@RequestMapping("/index/*")`可以匹配/index/lbwnb、/index/yyds等。 -* **:表示当前目录或基于当前目录的多级目录,比如`@RequestMapping("/index/**")`可以匹配/index、/index/xxx等。 - -我们接着来看下一个method属性,顾名思义,它就是请求的方法类型,我们可以限定请求方式,比如: - -```java -@RequestMapping(value = "/index", method = RequestMethod.POST) -public ModelAndView index(){ - return new ModelAndView("index"); -} -``` - -现在我们如果直接使用浏览器访问此页面,会显示405方法不支持,因为浏览器默认是直接使用GET方法获取页面,而我们这里指定为POST方法访问此地址,所以访问失败,我们现在再去端点中用POST方式去访问,成功得到页面。 - -我们也可以使用衍生注解直接设定为指定类型的请求映射: - -```java -@PostMapping(value = "/index") -public ModelAndView index(){ - return new ModelAndView("index"); -} -``` - -这里使用了`@PostMapping`直接指定为POST请求类型的请求映射,同样的,还有`@GetMapping`可以直接指定为GET请求方式,这里就不一一列举了。 - -我们可以使用`params`属性来指定请求必须携带哪些请求参数,比如: - -```java -@RequestMapping(value = "/index", params = {"username", "password"}) -public ModelAndView index(){ - return new ModelAndView("index"); -} -``` - -比如这里我们要求请求中必须携带`username`和`password`属性,否则无法访问。它还支持表达式,比如我们可以这样编写: - -```java -@RequestMapping(value = "/index", params = {"!username", "password"}) -public ModelAndView index(){ - return new ModelAndView("index"); -} -``` - -在username之前添加一个感叹号表示请求的不允许携带此参数,否则无法访问,我们甚至可以直接设定一个固定值: - -```java -@RequestMapping(value = "/index", params = {"username!=test", "password=123"}) -public ModelAndView index(){ - return new ModelAndView("index"); -} -``` - -这样,请求参数username不允许为test,并且password必须为123,否则无法访问。 - -`header`属性用法与`params`一致,但是它要求的是请求头中需要携带什么内容,比如: - -```java -@RequestMapping(value = "/index", headers = "!Connection") -public ModelAndView index(){ - return new ModelAndView("index"); -} -``` - -那么,如果请求头中携带了`Connection`属性,将无法访问。其他两个属性: - -* consumes: 指定处理请求的提交内容类型(Content-Type),例如application/json, text/html; -* produces: 指定返回的内容类型,仅当request请求头中的(Accept)类型中包含该指定类型才返回; - -### @RequestParam和@RequestHeader详解 - -我们接着来看,如何获取到请求中的参数。 - -我们只需要为方法添加一个形式参数,并在形式参数前面添加`@RequestParam`注解即可: - -```java -@RequestMapping(value = "/index") -public ModelAndView index(@RequestParam("username") String username){ - System.out.println("接受到请求参数:"+username); - return new ModelAndView("index"); -} -``` - -我们需要在`@RequestParam`中填写参数名称,参数的值会自动传递给形式参数,我们可以直接在方法中使用,注意,如果参数名称与形式参数名称相同,即使不添加`@RequestParam`也能获取到参数值。 - -一旦添加`@RequestParam`,那么此请求必须携带指定参数,我们也可以将require属性设定为false来将属性设定为非必须: - -```java -@RequestMapping(value = "/index") -public ModelAndView index(@RequestParam(value = "username", required = false) String username){ - System.out.println("接受到请求参数:"+username); - return new ModelAndView("index"); -} -``` - -我们还可以直接设定一个默认值,当请求参数缺失时,可以直接使用默认值: - -```java -@RequestMapping(value = "/index") -public ModelAndView index(@RequestParam(value = "username", required = false, defaultValue = "伞兵一号") String username){ - System.out.println("接受到请求参数:"+username); - return new ModelAndView("index"); -} -``` - -如果需要使用Servlet原本的一些类,比如: - -```java -@RequestMapping(value = "/index") -public ModelAndView index(HttpServletRequest request){ - System.out.println("接受到请求参数:"+request.getParameterMap().keySet()); - return new ModelAndView("index"); -} -``` - -直接添加`HttpServletRequest`为形式参数即可,SpringMVC会自动传递该请求原本的`HttpServletRequest`对象,同理,我们也可以添加`HttpServletResponse`作为形式参数,甚至可以直接将HttpSession也作为参数传递: - -```java -@RequestMapping(value = "/index") -public ModelAndView index(HttpSession session){ - System.out.println(session.getAttribute("test")); - session.setAttribute("test", "鸡你太美"); - return new ModelAndView("index"); -} -``` - -我们还可以直接将请求参数传递给一个实体类: - -```java -@Data -public class User { - String username; - String password; -} -``` - -注意必须携带set方法或是构造方法中包含所有参数,请求参数会自动根据类中的字段名称进行匹配: - -```java -@RequestMapping(value = "/index") -public ModelAndView index(User user){ - System.out.println("获取到cookie值为:"+user); - return new ModelAndView("index"); -} -``` - -`@RequestHeader`与`@RequestParam`用法一致,不过它是用于获取请求头参数的,这里就不再演示了。 - -### @CookieValue和@SessionAttrbutie - -通过使用`@CookieValue`注解,我们也可以快速获取请求携带的Cookie信息: - -```java -@RequestMapping(value = "/index") -public ModelAndView index(HttpServletResponse response, - @CookieValue(value = "test", required = false) String test){ - System.out.println("获取到cookie值为:"+test); - response.addCookie(new Cookie("test", "lbwnb")); - return new ModelAndView("index"); -} -``` - -同样的,Session也能使用注解快速获取: - -```java -@RequestMapping(value = "/index") -public ModelAndView index(@SessionAttribute(value = "test", required = false) String test, - HttpSession session){ - session.setAttribute("test", "xxxx"); - System.out.println(test); - return new ModelAndView("index"); -} -``` - -可以发现,通过使用SpringMVC框架,整个Web应用程序的开发变得非常简单,大部分功能只需要一个注解就可以搞定了,正是得益于Spring框架,SpringMVC才能大显身手。 - -### 重定向和请求转发 - -重定向和请求转发也非常简单,我们只需要在视图名称前面添加一个前缀即可,比如重定向: - -```java -@RequestMapping("/index") -public String index(){ - return "redirect:home"; -} - -@RequestMapping("/home") -public String home(){ - return "home"; -} -``` - -通过添加`redirect:`前缀,就可以很方便地实现重定向,那么请求转发呢,其实也是一样的,使用`forward:`前缀表示转发给其他请求映射: - -```java -@RequestMapping("/index") -public String index(){ - return "forward:home"; -} - -@RequestMapping("/home") -public String home(){ - return "home"; -} -``` - -使用SpringMVC,只需要一个前缀就可以实现重定向和请求转发,非常方便。 - -### Bean的Web作用域 - -在学习Spring时我们讲解了Bean的作用域,包括`singleton`和`prototype`,Bean分别会以单例和多例模式进行创建,而在SpringMVC中,它的作用域被继续细分: - -* request:对于每次HTTP请求,使用request作用域定义的Bean都将产生一个新实例,请求结束后Bean也消失。 -* session:对于每一个会话,使用session作用域定义的Bean都将产生一个新实例,会话过期后Bean也消失。 -* global session:不常用,不做讲解。 - -这里我们创建一个测试类来试试看: - -```java -public class TestBean { - -} -``` - -接着将其注册为Bean,注意这里需要添加`@RequestScope`或是`@SessionScope`表示此Bean的Web作用域: - -```java -@Bean -@RequestScope -public TestBean testBean(){ - return new TestBean(); -} -``` - -接着我们将其自动注入到Controller中: - -```java -@Controller -public class MainController { - - @Resource - TestBean bean; - - @RequestMapping(value = "/index") - public ModelAndView index(){ - System.out.println(bean); - return new ModelAndView("index"); - } -} -``` - -我们发现,每次发起得到的Bean实例都不同,接着我们将其作用域修改为`@SessionScope`,这样作用域就上升到Session,只要清理浏览器的Cookie,那么都会被认为是同一个会话,只要是同一个会话,那么Bean实例始终不变。 - -实际上,它也是通过代理实现的,我们调用Bean中的方法会被转发到真正的Bean对象去执行。 - -*** - -## RestFul风格 - -中文释义为**“表现层状态转换”**(名字挺高大上的),它不是一种标准,而是一种设计风格。它的主要作用是充分并正确利用HTTP协议的特性,规范资源获取的URI路径。通俗的讲,RESTful风格的设计允许将参数通过URL拼接传到服务端,目的是让URL看起来更简洁实用,并且我们可以充分使用多种HTTP请求方式(POST/GET/PUT/DELETE),来执行相同请求地址的不同类型操作。 - -因此,这种风格的连接,我们就可以直接从请求路径中读取参数,比如: - -`http://localhost:8080/mvc/index/123456` - -我们可以直接将index的下一级路径作为请求参数进行处理,也就是说现在的请求参数包含在了请求路径中: - -```java -@RequestMapping("/index/{str}") -public String index(@PathVariable String str) { - System.out.println(str); - return "index"; -} -``` - -注意请求路径我们可以手动添加类似占位符一样的信息,这样占位符位置的所有内容都会被作为请求参数,而方法的形参列表中必须包括一个与占位符同名的并且添加了`@PathVariable`注解的参数,或是由`@PathVariable`注解指定为占位符名称: - -```java -@RequestMapping("/index/{str}") -public String index(@PathVariable("str") String text){ - System.out.println(text); - return "index"; -} -``` - -如果没有配置正确,方法名称上会出现黄线。 - -我们可以按照不同功能进行划分: - -* POST http://localhost:8080/mvc/index - 添加用户信息,携带表单数据 -* GET http://localhost:8080/mvc/index/{id} - 获取用户信息,id直接放在请求路径中 -* PUT http://localhost:8080/mvc/index - 修改用户信息,携带表单数据 -* DELETE http://localhost:8080/mvc/index/{id} - 删除用户信息,id直接放在请求路径中 - -我们分别编写四个请求映射: - -```java -@Controller -public class MainController { - - @RequestMapping(value = "/index/{id}", method = RequestMethod.GET) - public String get(@PathVariable("id") String text){ - System.out.println("获取用户:"+text); - return "index"; - } - - @RequestMapping(value = "/index", method = RequestMethod.POST) - public String post(String username){ - System.out.println("添加用户:"+username); - return "index"; - } - - @RequestMapping(value = "/index/{id}", method = RequestMethod.DELETE) - public String delete(@PathVariable("id") String text){ - System.out.println("删除用户:"+text); - return "index"; - } - - @RequestMapping(value = "/index", method = RequestMethod.PUT) - public String put(String username){ - System.out.println("修改用户:"+username); - return "index"; - } -} -``` - -这只是一种设计风格而已,各位小伙伴了解即可。 - -*** - -## Interceptor拦截器 - -拦截器是整个SpringMVC的一个重要内容,拦截器与过滤器类似,都是用于拦截一些非法请求,但是我们之前讲解的过滤器是作用于Servlet之前,只有经过层层的拦截器才可以成功到达Servlet,而拦截器并不是在Servlet之前,它在Servlet与RequestMapping之间,相当于DispatcherServlet在将请求交给对应Controller中的方法之前进行拦截处理,它只会拦截所有Controller中定义的请求映射对应的请求(不会拦截静态资源),这里一定要区分两者的不同。 - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fupload-images.jianshu.io%2Fupload_images%2F4685968-ca4e9021f653c954.png%3FimageMogr2%2Fauto-orient%2Fstrip%257CimageView2%2F2%2Fw%2F1240&refer=http%3A%2F%2Fupload-images.jianshu.io&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1642340637&t=70d3dd6b52ae01ac76c04d99e6bd95ed) - -### 创建拦截器 - -创建一个拦截器我们需要实现一个`HandlerInterceptor`接口: - -```java -public class MainInterceptor implements HandlerInterceptor { - @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { - System.out.println("我是处理之前!"); - return true; //只有返回true才会继续,否则直接结束 - } - - @Override - public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { - System.out.println("我是处理之后!"); - } - - @Override - public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { - System.out.println("我是完成之后!"); - } -} -``` - -接着我们需要在配置类中进行注册: - -```java -@Override -public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(new MainInterceptor()) - .addPathPatterns("/**") //添加拦截器的匹配路径,只要匹配一律拦截 - .excludePathPatterns("/home"); //拦截器不进行拦截的路径 -} -``` - -现在我们在浏览器中访问index页面,拦截器已经生效。 - -得到整理拦截器的执行顺序: - ->我是处理之前! -> ->我是处理! -> ->我是处理之后! -> ->我是完成之后! - -也就是说,处理前和处理后,包含了真正的请求映射的处理,在整个流程结束后还执行了一次`afterCompletion`方法,其实整个过程与我们之前所认识的Filter类似,不过在处理前,我们只需要返回true或是false表示是否被拦截即可,而不是再去使用FilterChain进行向下传递。 - -那么我们就来看看,如果处理前返回false,会怎么样: - -> 我是处理之前! - -通过结果发现一旦返回false,之后的所有流程全部取消,那么如果是在处理中发生异常了呢? - -```java -@RequestMapping("/index") -public String index(){ - System.out.println("我是处理!"); - if(true) throw new RuntimeException(""); - return "index"; -} -``` - -结果为: - -> 我是处理之前! -> 我是处理! -> 我是完成之后! - -我们发现如果处理过程中抛出异常,那么久不会执行处理后`postHandle`方法,但是会执行`afterCompletion`方法,我们可以在此方法中获取到抛出的异常。 - -### 多级拦截器 - -前面介绍了仅仅只有一个拦截器的情况,我们接着来看如果存在多个拦截器会如何执行,我们以同样的方式创建二号拦截器: - -```java -public class SubInterceptor implements HandlerInterceptor { - @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { - System.out.println("二号拦截器:我是处理之前!"); - return true; - } - - @Override - public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { - System.out.println("二号拦截器:我是处理之后!"); - } - - @Override - public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { - System.out.println("二号拦截器:我是完成之后!"); - } -} -``` - -注册二号拦截器: - -```java -@Override -public void addInterceptors(InterceptorRegistry registry) { - //一号拦截器 - registry.addInterceptor(new MainInterceptor()).addPathPatterns("/**").excludePathPatterns("/home"); - //二号拦截器 - registry.addInterceptor(new SubInterceptor()).addPathPatterns("/**"); -} -``` - -注意拦截顺序就是注册的顺序,因此拦截器会根据注册顺序依次执行,我们可以打开浏览器运行一次: - -> 一号拦截器:我是处理之前! -> 二号拦截器:我是处理之前! -> 我是处理! -> 二号拦截器:我是处理之后! -> 一号拦截器:我是处理之后! -> 二号拦截器:我是完成之后! -> 一号拦截器:我是完成之后! - -和多级Filter相同,在处理之前,是按照顺序从前向后进行拦截的,但是处理完成之后,就按照倒序执行处理后方法,而完成后是在所有的`postHandle`执行之后再同样的以倒序方式执行。 - -那么如果这时一号拦截器在处理前就返回了false呢? - -```java -@Override -public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { - System.out.println("一号拦截器:我是处理之前!"); - return false; -} -``` - -得到结果如下: - -> 一号拦截器:我是处理之前! - -我们发现,与单个拦截器的情况一样,一旦拦截器返回false,那么之后无论有无拦截器,都不再继续。 - -## 异常处理 - -当我们的请求映射方法中出现异常时,会直接展示在前端页面,这是因为SpringMVC为我们提供了默认的异常处理页面,当出现异常时,我们的请求会被直接转交给专门用于异常处理的控制器进行处理。 - -我们可以自定义一个异常处理控制器,一旦出现指定异常,就会转接到此控制器执行: - -```java -@ControllerAdvice -public class ErrorController { - - @ExceptionHandler(Exception.class) - public String error(Exception e, Model model){ //可以直接添加形参来获取异常 - e.printStackTrace(); - model.addAttribute("e", e); - return "500"; - } -} -``` - -接着我们编写一个专门显示异常的页面: - -```java - - - - - Title - - - 500 - 服务器出现了一个内部错误QAQ -
- - -``` - -接着修改: - -```java -@RequestMapping("/index") -public String index(){ - System.out.println("我是处理!"); - if(true) throw new RuntimeException("您的氪金力度不足,无法访问!"); - return "index"; -} -``` - -访问后,我们发现控制台会输出异常信息,同时页面也是我们自定义的一个页面。 - -## JSON数据格式与AJAX请求 - -JSON (JavaScript Object Notation, JS 对象简谱) 是一种轻量级的数据交换格式。 - -我们现在推崇的是前后端分离的开发模式,而不是所有的内容全部交给后端渲染再发送给浏览器,也就是说,整个Web页面的内容在一开始就编写完成了,而其中的数据由前端执行JS代码来向服务器动态获取,再到前端进行渲染(填充),这样可以大幅度减少后端的压力,并且后端只需要传输关键数据即可(在即将到来的SpringBoot阶段,我们将完全采用前后端分离的开发模式) - -### JSON数据格式 - -既然要实现前后端分离,那么我们就必须约定一种更加高效的数据传输模式,来向前端页面传输后端提供的数据。因此JSON横空出世,它非常容易理解,并且与前端的兼容性极好,因此现在比较主流的数据传输方式则是通过JSON格式承载的。 - -一个JSON格式的数据长这样,以学生对象为例: - -```json -{"name": "杰哥", "age": 18} -``` - -多个学生可以以数组的形式表示: - -```json -[{"name": "杰哥", "age": 18}, {"name": "阿伟", "age": 18}] -``` - -嵌套关系可以表示为: - -```json -{"studentList": [{"name": "杰哥", "age": 18}, {"name": "阿伟", "age": 18}], "count": 2} -``` - -它直接包括了属性的名称和属性的值,与JavaScript的对象极为相似,它到达前端后,可以直接转换为对象,以对象的形式进行操作和内容的读取,相当于以字符串形式表示了一个JS对象,我们可以直接在控制台窗口中测试: - -```js -let obj = JSON.parse('{"studentList": [{"name": "杰哥", "age": 18}, {"name": "阿伟", "age": 18}], "count": 2}') -//将JSON格式字符串转换为JS对象 -obj.studentList[0].name //直接访问第一个学生的名称 -``` - -我们也可以将JS对象转换为JSON字符串: - -```js -JSON.stringify(obj) -``` - -我们后端就可以以JSON字符串的形式向前端返回数据,这样前端在拿到数据之后,就可以快速获取,非常方便。 - -那么后端如何快速创建一个JSON格式的数据呢?我们首先需要导入以下依赖: - -```xml - - com.alibaba - fastjson - 1.2.78 - -``` - -JSON解析框架有很多种,比较常用的是Jackson和FastJSON,这里我们使用阿里巴巴的FastJSON进行解析。 - -首先要介绍的是JSONObject,它和Map的使用方法一样(实现了Map接口),比如我们向其中存放几个数据: - -```java -@RequestMapping(value = "/index") -public String index(){ - JSONObject object = new JSONObject(); - object.put("name", "杰哥"); - object.put("age", 18); - System.out.println(object.toJSONString()); //以JSON格式输出JSONObject字符串 - return "index"; -} -``` - -最后我们得到的结果为: - -```json -{"name": "杰哥", "age": 18} -``` - -实际上JSONObject就是对JSON数据的一种对象表示。同样的还有JSONArray,它表示一个数组,用法和List一样,数组中可以嵌套其他的JSONObject或是JSONArray: - -```java -@RequestMapping(value = "/index") -public String index(){ - JSONObject object = new JSONObject(); - object.put("name", "杰哥"); - object.put("age", 18); - JSONArray array = new JSONArray(); - array.add(object); - System.out.println(array.toJSONString()); - return "index"; -} -``` - -得到的结果为: - -```json -[{"name": "杰哥", "age": 18}] -``` - -当出现循环引用时,会按照以下语法来解析:![img](https://img2018.cnblogs.com/blog/1758707/201908/1758707-20190814212141691-1998020800.png) - -我们可以也直接创建一个实体类,将实体类转换为JSON格式的数据: - -```java -@RequestMapping(value = "/index", produces = "application/json") -@ResponseBody -public String data(){ - Student student = new Student(); - student.setName("杰哥"); - student.setAge(18); - return JSON.toJSONString(student); -} -``` - -这里我们修改了`produces`的值,将返回的内容类型设定为`application/json`,表示服务器端返回了一个JSON格式的数据(当然不设置也行,也能展示,这样是为了规范)然后我们在方法上添加一个`@ResponseBody`表示方法返回(也可以在类上添加`@RestController`表示此Controller默认返回的是字符串数据)的结果不是视图名称而是直接需要返回一个字符串作为页面数据,这样,返回给浏览器的就是我们直接返回的字符串内容。 - -接着我们使用JSON工具类将其转换为JSON格式的字符串,打开浏览器,得到JSON格式数据。 - -SpringMVC非常智能,我们可以直接返回一个对象类型,它会被自动转换为JSON字符串格式: - -```java -@RequestMapping(value = "/data", produces = "application/json") -@ResponseBody -public Student data(){ - Student student = new Student(); - student.setName("杰哥"); - student.setAge(18); - return student; -} -``` - -注意需要在配置类中添加一下FastJSON转换器(默认只支持JackSon): - -```java -@Override -public void configureMessageConverters(List> converters) { - converters.add(new FastJsonHttpMessageConverter()); -} -``` - -### AJAX请求 - -前面我们讲解了如何向浏览器发送一个JSON格式的数据,那么我们现在来看看如何向服务器请求数据。 - -![AJAX](https://www.runoob.com/wp-content/uploads/2013/09/ajax-yl.png) - -Ajax即**A**synchronous **J**avascript **A**nd **X**ML(异步JavaScript和XML),它的目标就是实现页面中的数据动态更新,而不是直接刷新整个页面,它是一个概念。 - -它在JQuery框架中有实现,因此我们直接导入JQuery(JQuery极大地简化了JS的开发,封装了很多内容,感兴趣的可以了解一下): - -```html - -``` - -接着我们就可以直接使用了,首先修改一下前端页面: - -```html - - - - - Title - - - - - 你好, - - 您的年龄是: - - - - -``` - -现在我们希望用户名称和年龄需要在我们点击按钮之后才会更新,我们接着来编写一下JS: - -```js -function updateData() { - //美元符.的方式来使用Ajax请求,这里使用的是get方式,第一个参数为请求的地址(注意需要带上Web应用程序名称),第二个参数为成功获取到数据的方法,data就是返回的数据内容 - $.get("/mvc/data", function (data) { //获取成功执行的方法 - window.alert('接受到异步请求数据:'+JSON.stringify(data)) //弹窗展示数据 - $("#username").text(data.name) //这里使用了JQuery提供的选择器,直接选择id为username的元素,更新数据 - $("#age").text(data.age) - }) -} -``` - -使用JQuery非常方便,我们直接通过JQuery的选择器就可以快速获取页面中的元素,注意这里获取的元素是被JQuery封装过的元素,需要使用JQuery提供的方法来进行操作。 - -这样,我们就实现了从服务端获取数据并更新到页面中(实际上之前,我们在JavaWeb阶段使用XHR请求也演示过,不过当时是纯粹的数据) - -那么我们接着来看,如何向服务端发送一个JS对象数据并进行解析: - -```js -function submitData() { - $.post("/mvc/submit", { //这里使用POST方法发送请求 - name: "测试", //第二个参数是要传递的对象,会以表单数据的方式发送 - age: 18 - }, function (data) { - window.alert(JSON.stringify(data)) //发送成功执行的方法 - }) -} -``` - -服务器端只需要在请求参数位置添加一个对象接收即可(和前面是一样的,因为这里也是提交的表单数据): - -```java -@RequestMapping("/submit") -@ResponseBody -public String submit(Student student){ - System.out.println("接收到前端数据:"+student); - return "{\"success\": true}"; -} -``` - -我们也可以将js对象转换为JSON字符串的形式进行传输,这里需要使用ajax方法来处理: - -```js -function submitData() { - $.ajax({ //最基本的请求方式,需要自己设定一些参数 - type: 'POST', //设定请求方法 - url: "/mvc/submit", //请求地址 - data: JSON.stringify({name: "测试", age: 18}), //转换为JSON字符串进行发送 - success: function (data) { - window.alert(JSON.stringify(data)) - }, - contentType: "application/json" //请求头Content-Type一定要设定为JSON格式 - }) -} -``` - -如果我们需要读取前端发送给我们的JSON格式数据,那么这个时候就需要添加`@RequestBody`注解: - -```java -@RequestMapping("/submit") -@ResponseBody -public String submit(@RequestBody JSONObject object){ - System.out.println("接收到前端数据:"+object); - return "{\"success\": true}"; -} -``` - -这样,我们就实现了前后端使用JSON字符串进行通信。 - -## 实现文件上传和下载 - -利用SpringMVC,我们可以很轻松地实现文件上传和下载,同样的,我们只需要配置一个Resolver: - -```java -@Bean("multipartResolver") //注意这里Bean的名称是固定的,必须是multipartResolver -public CommonsMultipartResolver commonsMultipartResolver(){ - CommonsMultipartResolver resolver = new CommonsMultipartResolver(); - resolver.setMaxUploadSize(1024 * 1024 * 10); //最大10MB大小 - resolver.setDefaultEncoding("UTF-8"); //默认编码格式 - return resolver; -} -``` - -接着我们直接编写Controller即可: - -```java -@RequestMapping(value = "/upload", method = RequestMethod.POST) -@ResponseBody -public String upload(@RequestParam CommonsMultipartFile file) throws IOException { - File fileObj = new File("test.html"); - file.transferTo(fileObj); - System.out.println("用户上传的文件已保存到:"+fileObj.getAbsolutePath()); - return "文件上传成功!"; -} -``` - -使用CommonsMultipartFile对象来接收用户上传的文件。它是基于Apache的Commons-fileupload框架实现的,我们还需要导入一个依赖: - -```xml - - commons-fileupload - commons-fileupload - 1.4 - -``` - -最后在前端添加一个文件的上传点: - -```html -
-
- - -
-
-``` - -这样,点击提交之后,文件就会上传到服务器了。 - -下载其实和我们之前的写法大致一样,直接使用HttpServletResponse,并向输出流中传输数据即可。 - -```java -@RequestMapping(value = "/download", method = RequestMethod.GET) -@ResponseBody -public void download(HttpServletResponse response){ - response.setContentType("multipart/form-data"); - try(OutputStream stream = response.getOutputStream(); - InputStream inputStream = new FileInputStream("test.html")){ - IOUtils.copy(inputStream, stream); - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -在前端页面中添加一个下载点: - -```html -
下载最新资源 -``` - -## 解读DispatcherServlet源码 - -**注意:**本部分作为选学内容! - -到目前为止,关于SpringMVC的相关内容就学习得差不多了,但是我们在最后还是需要深入了解一下DispatcherServlet底层是如何进行调度的,因此,我们会从源码角度进行讲解。 - -首先我们需要找到`DispatcherServlet`的最顶层`HttpServletBean`,在这里直接继承的`HttpServlet`,那么我们首先来看一下,它在初始化方法中做了什么: - -```java -public final void init() throws ServletException { - //读取配置参数,并进行配置 - PropertyValues pvs = new HttpServletBean.ServletConfigPropertyValues(this.getServletConfig(), this.requiredProperties); - if (!pvs.isEmpty()) { - try { - BeanWrapper bw = PropertyAccessorFactory.forBeanPropertyAccess(this); - ResourceLoader resourceLoader = new ServletContextResourceLoader(this.getServletContext()); - bw.registerCustomEditor(Resource.class, new ResourceEditor(resourceLoader, this.getEnvironment())); - this.initBeanWrapper(bw); - bw.setPropertyValues(pvs, true); - } catch (BeansException var4) { - if (this.logger.isErrorEnabled()) { - this.logger.error("Failed to set bean properties on servlet '" + this.getServletName() + "'", var4); - } - - throw var4; - } - } - //此初始化阶段由子类实现, - this.initServletBean(); -} -``` - -我们接着来看`initServletBean()`方法是如何实现的,它是在子类`FrameworkServlet`中定义的: - -```java -protected final void initServletBean() throws ServletException { - this.getServletContext().log("Initializing Spring " + this.getClass().getSimpleName() + " '" + this.getServletName() + "'"); - if (this.logger.isInfoEnabled()) { - this.logger.info("Initializing Servlet '" + this.getServletName() + "'"); - } - - long startTime = System.currentTimeMillis(); - - try { - //注意:我们在一开始说了SpringMVC有两个容器,一个是Web容器一个是根容器 - //Web容器只负责Controller等表现层内容 - //根容器就是Spring容器,它负责Service、Dao等,并且它是Web容器的父容器。 - //初始化WebApplicationContext,这个阶段会为根容器和Web容器进行父子关系建立 - this.webApplicationContext = this.initWebApplicationContext(); - this.initFrameworkServlet(); - } catch (RuntimeException | ServletException var4) { - //...以下内容全是打印日志 -} -``` - -![img](https://images2018.cnblogs.com/blog/1290804/201712/1290804-20171209164442730-1374080285.png) - -我们来看看`initWebApplicationContext`是如何进行初始化的: - -```java -protected WebApplicationContext initWebApplicationContext() { - //这里获取的是根容器,一般用于配置Service、数据源等 - WebApplicationContext rootContext = WebApplicationContextUtils.getWebApplicationContext(this.getServletContext()); - WebApplicationContext wac = null; - if (this.webApplicationContext != null) { - //如果webApplicationContext在之前已经存在,则直接给到wac - wac = this.webApplicationContext; - if (wac instanceof ConfigurableWebApplicationContext) { - ConfigurableWebApplicationContext cwac = (ConfigurableWebApplicationContext)wac; - if (!cwac.isActive()) { - if (cwac.getParent() == null) { - //设定根容器为Web容器的父容器 - cwac.setParent(rootContext); - } - - this.configureAndRefreshWebApplicationContext(cwac); - } - } - } - - if (wac == null) { - //如果webApplicationContext是空,那么就从ServletContext找一下有没有初始化上下文 - wac = this.findWebApplicationContext(); - } - - if (wac == null) { - //如果还是找不到,直接创个新的,并直接将根容器作为父容器 - wac = this.createWebApplicationContext(rootContext); - } - - if (!this.refreshEventReceived) { - synchronized(this.onRefreshMonitor) { - //此方法由DispatcherServlet实现 - this.onRefresh(wac); - } - } - - if (this.publishContext) { - String attrName = this.getServletContextAttributeName(); - //把Web容器丢进ServletContext - this.getServletContext().setAttribute(attrName, wac); - } - - return wac; -} -``` - -我们接着来看DispatcherServlet中实现的`onRefresh()`方法: - -```java -@Override -protected void onRefresh(ApplicationContext context) { - initStrategies(context); -} - -protected void initStrategies(ApplicationContext context) { - //初始化各种解析器 - initMultipartResolver(context); - initLocaleResolver(context); - initThemeResolver(context); - //在容器中查找所有的HandlerMapping,放入集合中 - //HandlerMapping保存了所有的请求映射信息(Controller中定义的),它可以根据请求找到处理器Handler,但并不是简单的返回处理器,而是将处理器和拦截器封装,形成一个处理器执行链(类似于之前的Filter) - initHandlerMappings(context); - //在容器中查找所有的HandlerAdapter,它用于处理请求并返回ModelAndView对象 - //默认有三种实现HttpRequestHandlerAdapter,SimpleControllerHandlerAdapter和AnnotationMethodHandlerAdapter - //当HandlerMapping找到处理请求的Controller之后,会选择一个合适的HandlerAdapter处理请求 - //比如我们之前使用的是注解方式配置Controller,现在有一个请求携带了一个参数,那么HandlerAdapter会对请求的数据进行解析,并传入方法作为实参,最后根据方法的返回值将其封装为ModelAndView对象 - initHandlerAdapters(context); - //其他的内容 - initHandlerExceptionResolvers(context); - initRequestToViewNameTranslator(context); - initViewResolvers(context); - initFlashMapManager(context); -} -``` - -DispatcherServlet初始化过程我们已经了解了,那么我们接着来看DispatcherServlet是如何进行调度的,首先我们的请求肯定会经过`HttpServlet`,然后其交给对应的doGet、doPost等方法进行处理,而在`FrameworkServlet`中,这些方法都被重写,并且使用`processRequest`来进行处理: - -```java -protected final void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { - this.processRequest(request, response); -} - -protected final void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { - this.processRequest(request, response); -} -``` - -我们来看看`processRequest`做了什么: - -```java -protected final void processRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { - //前期准备工作 - long startTime = System.currentTimeMillis(); - Throwable failureCause = null; - LocaleContext previousLocaleContext = LocaleContextHolder.getLocaleContext(); - LocaleContext localeContext = this.buildLocaleContext(request); - RequestAttributes previousAttributes = RequestContextHolder.getRequestAttributes(); - ServletRequestAttributes requestAttributes = this.buildRequestAttributes(request, response, previousAttributes); - WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request); - asyncManager.registerCallableInterceptor(FrameworkServlet.class.getName(), new FrameworkServlet.RequestBindingInterceptor()); - this.initContextHolders(request, localeContext, requestAttributes); - - try { - //重点在这里,这里进行了Service的执行,不过是在DispatcherServlet中定义的 - this.doService(request, response); - } catch (IOException | ServletException var16) { - //... -} -``` - -请各位一定要耐心,这些大型框架的底层一般都是层层套娃,因为这样写起来层次会更加清晰,那么我们来看看`DispatcherServlet`中是如何实现的: - -```java -protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception { - //... - try { - //重点在这里,这才是整个处理过程中最核心的部分 - this.doDispatch(request, response); - } finally { - //... -} -``` - -终于找到最核心的部分了: - -```java -protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception { - HttpServletRequest processedRequest = request; - HandlerExecutionChain mappedHandler = null; - boolean multipartRequestParsed = false; - WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request); - - try { - try { - ModelAndView mv = null; - Object dispatchException = null; - - try { - processedRequest = this.checkMultipart(request); - multipartRequestParsed = processedRequest != request; - //在HandlerMapping集合中寻找可以处理当前请求的HandlerMapping - mappedHandler = this.getHandler(processedRequest); - if (mappedHandler == null) { - this.noHandlerFound(processedRequest, response); - //找不到HandlerMapping则无法进行处理 - return; - } - - //根据HandlerMapping提供的信息,找到可以处理的HandlerAdapter - HandlerAdapter ha = this.getHandlerAdapter(mappedHandler.getHandler()); - String method = request.getMethod(); - boolean isGet = HttpMethod.GET.matches(method); - if (isGet || HttpMethod.HEAD.matches(method)) { - long lastModified = ha.getLastModified(request, mappedHandler.getHandler()); - if ((new ServletWebRequest(request, response)).checkNotModified(lastModified) && isGet) { - return; - } - } - - //执行所有拦截器的preHandle()方法 - if (!mappedHandler.applyPreHandle(processedRequest, response)) { - return; - } - - //使用HandlerAdapter进行处理(我们编写的请求映射方法在这个位置才真正地执行了) - //HandlerAdapter会帮助我们将请求的数据进行处理,再来调用我们编写的请求映射方法 - //最后HandlerAdapter会将结果封装为ModelAndView返回给mv - mv = ha.handle(processedRequest, response, mappedHandler.getHandler()); - if (asyncManager.isConcurrentHandlingStarted()) { - return; - } - - this.applyDefaultViewName(processedRequest, mv); - //执行所有拦截器的postHandle()方法 - mappedHandler.applyPostHandle(processedRequest, response, mv); - } catch (Exception var20) { - dispatchException = var20; - } catch (Throwable var21) { - dispatchException = new NestedServletException("Handler dispatch failed", var21); - } - - //最后处理结果,对视图进行渲染等,如果抛出异常会出现错误页面 - this.processDispatchResult(processedRequest, response, mappedHandler, mv, (Exception)dispatchException); - } catch (Exception var22) { - this.triggerAfterCompletion(processedRequest, response, mappedHandler, var22); - } catch (Throwable var23) { - this.triggerAfterCompletion(processedRequest, response, mappedHandler, new NestedServletException("Handler processing failed", var23)); - } - - } finally { - if (asyncManager.isConcurrentHandlingStarted()) { - if (mappedHandler != null) { - mappedHandler.applyAfterConcurrentHandlingStarted(processedRequest, response); - } - } else if (multipartRequestParsed) { - this.cleanupMultipart(processedRequest); - } - - } -} -``` - -所以,根据以上源码分析得出最终的流程图: - -![img](https://img2018.cnblogs.com/blog/1338162/201901/1338162-20190113192808388-307235311.png) - -虽然完成本章学习后,我们已经基本能够基于Spring去重新编写一个更加高级的图书管理系统了,但是登陆验证复杂的问题依然没有解决,如果我们依然按照之前的方式编写登陆验证,显然太过简单,它仅仅只是一个登陆,但是没有任何的权限划分或是加密处理,我们需要更加高级的权限校验框架来帮助我们实现登陆操作,下一章,我们会详细讲解如何使用更加高级的SpringSecurity框架来进行权限验证,并在学习的过程中,重写我们的图书管理系统。 \ No newline at end of file diff --git a/青空笔记/JavaSSM笔记/JavaSSM笔记(四).md b/青空笔记/JavaSSM笔记/JavaSSM笔记(四).md deleted file mode 100644 index f21e384..0000000 --- a/青空笔记/JavaSSM笔记/JavaSSM笔记(四).md +++ /dev/null @@ -1,868 +0,0 @@ -# MySQL高级 - -在JavaWeb阶段,我们初步认识了MySQL数据库,包括一些基本操作,比如创建数据库、表、触发器,以及最基本的增删改查、事务等操作。而在此阶段,我们将继续深入学习MySQL,了解它的更多高级玩法,也相当于进行复习。 - -## 函数 - -其实函数我们在之前已经接触到一部分了,在JavaWeb阶段,我们了解了聚集函数,聚集函数一般用作统计,包括: - -* count([distinct]*) 统计所有的行数(distinct表示去重再统计,下同) -* count([distinct]列名) 统计某列的值总和 -* sum([distinct]列名) 求一列的和(注意必须是数字类型的) -* avg([distinct]列名) 求一列的平均值(注意必须是数字类型) -* max([distinct]列名) 求一列的最大值 -* min([distinct]列名) 求一列的最小值 - -比如我们需要计算某个表一共有多少行: - -```sql -SELECT COUNT(*) FROM student -``` - -通过使用COUNT聚集函数,就可以快速统计并得到结果,比如我们想计算某一列上所有数字的和: - -```sql -SELECT SUM(sid) FROM student -``` - -通过SUM聚集函数,就可以快速计算每一列的和,实际上这些函数都是由系统提供的函数,我们可以直接使用。 - -本版块我们会详细介绍各类系统函数以及如何编写自定义函数。 - -### 系统函数 - -系统为我们提供的函数也是非常实用的,我们将会分为几个类型进行讲解。 - -#### 字符串函数 - -处理字符串是一个比较重要的内容,我们可以使用字符串函数来帮助我们快速处理字符串,其中常用比如用于字符串分割的函数有: - -* substring(字符串, 起始位置, 结束位置) 同Java中String类的substring一致,但是注意下标是从1开始,下同 -* left(字符串, 长度) 从最左边向右截取字符串 -* right(字符串, 长度) 从最右边向左截取字符串 - -比如我们只想获取所有学生姓名的第二个字,那么可以像这样写: - -```sql -SELECT SUBSTRING(name, 2, 2) FROM student -``` - -比如我们想获取所有学生姓名的第一个字,可以像这样写: - -```sql -SELECT LEFT(name, 1) FROM student -``` - -我们还可以利用字符串函数来快速将所有的字母转换为大写字母或是快速转换为小写字母: - -* upper(字符串) 字符串中的所有字母转换为大写字母 -* lower(字符串) 字符串中的所有字母转换为小写字母 - -比如我们希望将一个字符串所有字符专为大写: - -```sql -SELECT UPPER('abcdefg') -``` - -我们也可以像Java中那样直接对字符串中的内容进行替换: - -* replace(字符串, 原文, 替换文) 同Java中String的replace效果 - -比如现在我们希望将查询到的所有同学的名称中的`小`全部替换`大`: - -```sql -SELECT REPLACE(`name`, '小', '大') FROM student -``` - -字符串也支持进行拼接,系统提供了字符串的拼接函数: - -* concat(字符串1, 字符串2) 连接两个字符串 - -比如我们希望将查询到的所有同学的名称最后都添加一个`子`字: - -```sql -SELECT concat(name, '子') FROM student -``` - -最后就是计算字符串的长度: - -* length(字符串) 获取字符串长度(注意如果使用的是UTF-8编码格式,那么一个汉字占3字节,数字和字母占一个字节) - -比如我们要获取所有人名字的长度: - -```sql -SELECT LENGTH(`name`) FROM student -``` - -#### 日期函数 - -MySQL提供的日期函数也非常实用,我们可以快速对日期进行操作,比如我们想要快速将日期添加N天,就可以使用: - -* date_add(日期, interval 增量 单位) - -比如我们希望让2022-1-1向后5天: - -```sql -SELECT DATE_ADD('2022-1-1',INTERVAL 5 day) -``` - -同理,向前1年: - -```sql -SELECT DATE_ADD('2022-1-1',INTERVAL -1 year) -``` - -单位有:year(年)、month(月)、day(日)、hour(小时)、minute(分钟)、second(秒) - -我们还可以快速计算日期的间隔天数: - -* datediff(日期1, 日期2) - -比如我们想计算2022年的2月有多少天: - -```sql -SELECT DATEDIFF('2022-3-1','2022-2-1') -``` - -如果我们想快速获取当前时间的话,可以使用这些: - -* curdate() 当前的日期 -* curtime() 当前的时间 -* now() 当前的日期+时间 - -此函数之前我们在编写实战项目的时候已经使用过了,这里就不演示了。我们也可以单独获取时间中的某个值: - -* day(日期) 获取日期是几号 -* month(日期) 获取日期是几月 -* year(日期) 获取日期是哪一年 - -比如我们想获取今天是几号: - -```sql -SELECT DAY(NOW()) -``` - -#### 数学函数 - -数学函数比较常规,基本与Java的Math工具类一致,这里列出即可,各位可以自行尝试: - -* abs(x) 求x的绝对值 -* ceiling(x) x向上取整 -* floor(x) x向下取整 -* round(x, 精度) x取四舍五入,遵循小数点精度 -* exp(x) e的x次方 -* rand() 0-1之间的随机数 -* log(x) x的对数 -* pi() π -* power(x, n) x的n次方 -* sqrt(x) x的平方根 -* sin(x) cos(x) tan(x) 三角函数(貌似没有arctan这类反函数?) - -#### 类型转换函数 - -MySQL的类型转换也分为隐式类型转换和显示类型转换,首先我们来看看隐式类型转换: - -```sql -SELECT 1+'2' -``` - -虽然这句中既包含了数字和字符,但是字符串会被进行隐式转换(注意这里并不是按照字符的ASCII码转换,而是写的多少表示多少)所以最后得到的就是1+2的结果为3 - -```sql -SELECT CONCAT(1, '2') -``` - -这里因为需要传入字符串类型的数据,但是我们给的是1这个数字,因此这里也会发生隐式类型转换,1会被直接转换为字符串的'1',所以这里得到的结果是'12' - -在某些情况下,我们可能需要使用强制类型转换来将得到的数据转换成我们需要的数据类型,这时就需要用到类型转换函数了,MySQL提供了: - -* cast(数据 as 数据类型) - -数据类型有以下几种: - -* BINARY[(N)] :二进制字符串,转换后长度小于N个字节 -* CHAR[(N)] :字符串,转换后长度小于N个字符 -* DATE :日期 -* DATETIME :日期时间 -* DECIMAL[(M[,N])] :浮点数,M为数字总位数(包括整数部分和小数部分),N为小数点后的位数 -* SIGNED [INTEGER] :有符号整数 -* TIME :时间 -* UNSIGNED [INTEGER] :无符号整数 - -比如我们现在需要将一个浮点数转换为一个整数: - -```sql -SELECT CAST(pi() AS SIGNED) -``` - -我们还可以将字符串转换为数字,会自动进行扫描,值得注意的是一旦遇到非数字的字符,会停止扫描: - -```sql -SELECT CAST('123abc456' as SIGNED) -``` - -除了cast以外还有convert函数,功能比较相似,这里就不做讲解了。 - -#### 流程控制函数 - -MySQL还为我们提供了很多的逻辑判断函数,比如: - -* if(条件表达式, 结果1, 结果2) 与Java中的三目运算符一致 a > b ? "AAA" : "BBB" -* ifnull(值1, 值2) 如果值1为NULL则返回值2,否则返回值1 -* nullif(值1, 值2) 如果值1与值2相等,那么返回NULL -* isnull(值) 判断值是否为NULL - -比如现在我们想判断: - -```sql -SELECT IF(1 < 0,'lbwnb','yyds') -``` - -通过判断函数,我们就可以很方便地进行一些条件判断操作。 - -除了IF条件判断,我们还可以使用类似Switch一样的语句完成多分支结构: - -```sql -SELECT -CASE 2 - WHEN 1 THEN - 10 - ELSE - 5 -END; -``` - -我们也可以将自定义的判断条件放入When之后,它类似于else-if: - -```sql -SELECT -CASE - WHEN 3>5 THEN - 10 - WHEN 0<1 THEN - 11 - ELSE - 5 -END; -``` - -还有一个类似于Java中的Thread.sleep的函数,以秒为单位: - -```sql -SELECT sleep(10); -``` - -有关MySQL8.0新增的窗口函数这里暂时不做介绍。 - -### 自定义函数 - -除了使用系统为我们提供的函数以外,我们也可以自定义函数,并使用我们自定义的函数进行数据处理,唯一比较麻烦的就是函数定义后不能修改,只能删了重新写。 - -#### 基本语法 - -MySQL的函数与Java中的方法类似,也可以添加参数和返回值,可以通过`CREATE FUNCTION`创建函数: - -```sql -CREATE FUNCTION test() RETURNS INT -BEGIN -RETURN 666; -END -``` - -定义函数的格式为: - -* create function 函数名称([参数列表]) returns 返回值类型 -* begin 和 end 之间写函数的其他逻辑,begin和end就相当于Java中的花括号`{ ... }` -* return后紧跟返回的结果 - -添加参数也很简单,我们只需要在函数名称括号中添加即可,注意类型需要写在参数名称后面: - -```sql -CREATE FUNCTION test(i INT) RETURNS INT -BEGIN -RETURN i * i; -END -``` - -我们可以在BEGIN和RETURN之间编写一些其他的逻辑,比如我们想要定义一个局部变量,并为其赋值: - -```sql -BEGIN -DECLARE a INT; -SET a = 10; -RETURN i * i * a; -END -``` - -定义局部变量的格式为: - -* declare 变量名称 变量类型 [, ...] -* declare 变量名称 变量类型 default 默认值 - -为变量赋值的格式为: - -* set 变量名称 = 值 - -我们还可以在函数内部使用`select`语句,它可以直接从表中读取数据,并可以结合into关键字将查询结果赋值给变量: - -```sql -BEGIN -DECLARE a INT; --- select into from 语句 -SELECT COUNT(*) INTO a FROM student; -RETURN a; -END -``` - -#### 流程控制 - -接着我们来看一下如何使用流程控制语句,其中最关键的就是IF判断语句: - -```sql -BEGIN -DECLARE a INT DEFAULT 10; -IF a > 10 THEN - RETURN 1; -ELSE - RETURN 2; -END IF; -END -``` - -IF分支语句的格式为: - -* if 判断条件 then ... else if 判断条件 then .... else ... end if; - -我们可以结合`exists`关键字来判断是否为NULL: - -```sql -BEGIN -DECLARE a INT DEFAULT 0; --- IF EXISTS(SELECT * FROM student WHERE sid = 100) THEN -IF NOT EXISTS(SELECT * FROM student WHERE sid = 100) THEN - SET a = 10; -END IF; -RETURN a; -END -``` - -我们也可以在函数中使用switch语句: - -```sql -BEGIN -DECLARE a INT DEFAULT 10; -CASE a - WHEN 10 THEN - RETURN 2; - ELSE - RETURN 1; -END CASE; -END -``` - -SWITCH分支语句的格式为: - -* case 变量 when 具体值或是布尔表达式 then ... when * then ... else ... end case; - -与Java不同的是,它支持使用布尔表达式: - -```sql -BEGIN -DECLARE a INT DEFAULT 10; -CASE - WHEN 1 < 5 THEN - SET a = 5; - ELSE - SET a = 10; -END CASE; -RETURN a; -END -``` - -我们以类似于elseif的形式进行判断,其实和上面直接使用是一样的。 - -我们接着来看循环语句,MySQL提供了三种循环语句,其中第一种是WHILE语句: - -```sql -BEGIN -DECLARE a INT DEFAULT 10; -WHILE a < 11 DO - SET a = a + 1; -END WHILE; -RETURN a; -END -``` - -格式为: - -* while 循环条件 do ... end while; - -我们接着来看第二种循环语句,LOOP循环: - -```sql -BEGIN -DECLARE a INT DEFAULT 10; -lp1: LOOP - SET a = a - 1; - IF a = 0 THEN - LEAVE lp1; - END IF; -END LOOP lp1; -RETURN a; -END -``` - -相比while语句,我们可以使用`LEAVE`精准控制结束哪个循环,有点类似于goto语句: - -```sql -BEGIN -DECLARE a INT DEFAULT 0; -lp1: LOOP - lp2: LOOP - SET a = a + 1; - IF a > 5 THEN - LEAVE lp1; - END IF; - END LOOP lp2; -END LOOP lp1; -RETURN a; -END -``` - -类似于Java中的goto写法(在JavaSE阶段已经讲解过): - -```java -public static void main(String[] args) { - int a = 0; - lp1: while (true){ - lp2: while (true){ - a++; - if(a > 5) break lp1; - } - } - System.out.println(a); -} -``` - -它的语法格式如下: - -* 循环名称 loop ...(可以插入leave语句结束) end loop 循环名称; - -接着我们来看最后一种循环语句,repeat语句: - -```sql -BEGIN -DECLARE a INT DEFAULT 0; -REPEAT - SET a = a + 1; -UNTIL a > 0 END REPEAT; -RETURN a; -END -``` - -它类似于Java中的do-while循环语句,它会先去执行里面的内容,再进行判断,格式为: - -* repeat ... until 结束循环条件 end repeat; - -#### 全局变量 - -某些情况下,我们可以直接在一次会话中直接定义变量并使用,这时它并不是位于函数内的,这就是全局变量,它无需预先定义,直接使用即可: - -```sql -set @x = 10; -``` - -我们可以将全局变量作为参数传递给函数: - -```sql -select test(@x); -``` - -除了我们自己定义的全部变量以外,系统默认也有很多的变量,因此我们自己定义的变量称为用户变量,系统默认变量称为系统变量。查看系统变量的命令为: - -```sql -show GLOBAL VARIABLES -``` - -*** - -## 存储过程 - -存储过程是一个包括多条SQL语句的集合,专用于特定表的特定操作,比如我们之前实战项目中的创建用户,那么我们就需要一次性为两张表添加数据,但是如果不使用Java,而是每次都去使用SQL命令来完成,就需要手动敲两次命令,非常麻烦,因此我们可以提前将这些操作定义好,预留出需要填写数据的位置,下次输入参数直接调用即可。 - -这里很容易与函数搞混淆,存储过程也是执行多条SQL语句,但是它们的出发点不一样,函数是专用于进行数据处理,并将结果返回给调用者,它更多情况下是一条SQL语句的参与者,无法直接运行,并且不涉及某个特定表: - -```sql -select count(*) from student; -``` - -而存储过程是多条SQL语句的执行者,这是它们的本质区别。 - -定义存储过程与定义函数极为相似,它也可以包含参数,函数中使用的语句这里也能使用,但是它没有返回值: - -```sql -CREATE PROCEDURE lbwnb(`name` VARCHAR(20), pwd VARCHAR(255)) -BEGIN - INSERT INTO users(username, `password`) VALUES(`name`, pwd); -END -``` - -我们可以在存储过程中编写多条SQL语句,但是注意,MySQL的存储过程不具有原子性,当出现错误时,并不会回滚之前的操作,因此需要我们自己来编写事务保证原子性。 - -接着我们来看看如何执行存储过程: - -```sql -CALL lbwnb('111', '2222') -``` - -通过使用`call`来执行一个存储过程,如果存储过程有参数,那么还需要填写参数。 - -比如现在我们想要实现查询用户表,如果包含用户`test`那么就删除用户,如果不包含,就添加用户: - -```sql -CREATE PROCEDURE `lbwnb`() -BEGIN - IF NOT EXISTS(SELECT * FROM users WHERE username = 'test') THEN - INSERT INTO users(username, `password`) VALUES('test', '123456'); - ELSE - DELETE FROM users WHERE username = 'test'; - END IF; -END -``` - -这里其实只需要一个简单的IF判断即可实现。 - -那么如果我们希望遍历一个SELECT语句查询的结果呢?我们可以使用游标来完成: - -```sql -BEGIN - DECLARE id INT; - DECLARE `name` VARCHAR(10); - DECLARE sex VARCHAR(5); - DECLARE cur CURSOR FOR SELECT * FROM student; - OPEN cur; - WHILE TRUE DO - FETCH cur INTO id, `name`, sex; - SELECT id, `name`, sex; - END WHILE; - CLOSE cur; -END -``` - -游标的使用分为4个步骤: - -* DECLARE 游标名称 CURSOR FOR 查询结果 - 定义游标 -* OPEN cur - 开启游标 -* FETCH 游标名称 INTO 存储结果的变量 - 从顶部开始,每执行一次,向下移动,如果已经在最底部,则触发异常 -* CLOSE cur - 关闭游标 - -我们这里利用了一个while循环来多次通过游标获取查询结果,但是最后是因为出现异常才退出的,这样会导致之后的代码就无法继续正常运行了。 - -我们接着来看如何处理异常: - -```sql -BEGIN - DECLARE id INT; - DECLARE `name` VARCHAR(10); - DECLARE sex VARCHAR(5); - DECLARE score INT; - DECLARE a INT DEFAULT 0; - DECLARE cur CURSOR FOR SELECT * FROM student; - -- 必须在游标定义之后编写 - DECLARE CONTINUE HANDLER FOR 1329 SET a = 1; - OPEN cur; - WHILE a = 0 DO - FETCH cur INTO id, `name`, sex, score; - SELECT id, `name`, sex, score; - END WHILE; - CLOSE cur; - SELECT 1; -END -``` - -我们可以声明一个异常处理器(句柄),格式如下: - -* declear (continue/exit) handler for 异常名称(ID) 做点什么 - -我们还可以限定存储过程的参数传递,比如我们只希望用户给我们一个参数用于接收数据,而不是值传递,我们可以将其设定为OUT类型: - -```sql -CREATE PROCEDURE `lbwnb`(OUT a INT) -BEGIN - SELECT a; - SET a = 100; -END -``` - -所有的参数默认为`IN`类型,也就是只能作为传入参数,无法为其赋值,而这里讲参数设定为`OUT`类型,那么参数无法将值传入,而只能被赋值。 - -如果我们既希望参数可以传入也可以被重新赋值,我们可以将其修改为`INOUT`类型。 - -*** - -## 存储引擎 - -存储引擎就像我们电脑中的CPU,它是整个MySQL最核心的部分,数据库中的数据如何存储,数据库能够支持哪些功能,我们的增删改查请求如何执行,都是由存储引擎来决定的。 - -我们可以大致了解一下以下三种存储引擎: - -* **MyISAM:**MySQL5.5之前的默认存储引擎,在插入和查询的情况下性能很高,但是它不支持事务,只能添加表级锁。 -* **InnoDB:**MySQL5.5之后的默认存储引擎,它支持ACID事务、行级锁、外键,但是性能比不过MyISAM,更加消耗资源。 -* **Memory:**数据都存放在内存中,数据库重启或发生崩溃,表中的数据都将消失。 - -我们可以使用下面的命令来查看MySQL支持的存储引擎: - -```sql -show engines; -``` - -在创建表时,我们也可以为表指定其存储引擎。 - -我们还可以在配置文件中修改默认的存储引擎,在Windows 11系统下,MySQL的配置文件默认放在`C:\ProgramData\MySQL\MySQL Server 5.7`中,注意ProgramData是个隐藏文件夹。 - -*** - -## 索引 - -**注意:**本小节会涉及`数据结构与算法`相关知识。 - -索引就好像我们书的目录,每本书都有一个目录用于我们快速定位我们想要的内容在哪一页,索引也是,通过建立索引,我们就可以根据索引来快速找到想要的一条记录,大大提高查询效率。 - -本版块我们会详细介绍索引的几种类型,以及索引的底层存储原理。 - -### 单列索引 - -单列索引只针对于某一列数据创建索引,单列索引有以下几种类型: - -* **NORMAL:**普通的索引类型,完完全全相当于一本书的目录。 -* **UNIQUE:**唯一索引,我们之前已经用过了,一旦建立唯一索引,那么整个列中将不允许出现重复数据。每个表的主键列,都有一个特殊的唯一索引,叫做Primary Key,它不仅仅要求不允许出现重复,还要求不能为NULL,它还可以自动递增。每张表可以有多个唯一索引,但是只能有一个Primary索引。 -* **SPATIAL:**空间索引,空间索引是对空间数据类型的字段建立的索引,MYSQL中的空间数据类型有4种,分别是GEOMETRY、POINT、LINESTRING、POLYGON,不是很常用,这里不做介绍。 -* **FULLTEXT:**全文索引(MySQL 5.6 之后InnoDB才支持),它是模糊匹配的一种更好的解决方案,它的效率要比使用`like %`更高,并且它还支持多种匹配方式,灵活性也更加强大。只有字段的数据类型为 char、varchar、text 及其系列才可以建全文索引。 - -我们来看看如何使用全文索引,首先创建一张用于测试全文索引的表: - -```sql -CREATE TABLE articles ( - id INT UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY, - title VARCHAR(200), - body TEXT, - FULLTEXT (body)); -``` - -```sql -INSERT INTO articles VALUES - (NULL,'MySQL Tutorial', 'DBMS stands for DataBase ...'), - (NULL,'How To Use MySQL Efficiently', 'After you went through a ...'), - (NULL,'Optimising MySQL','In this tutorial we will show ...'), - (NULL,'1001 MySQL Tricks','1. Never run mysqld as root. 2. ...'), - (NULL,'MySQL vs. YourSQL', 'In the following database comparison ...'), - (NULL,'MySQL Security', 'When configured properly, MySQL ...'); -``` - -最后我们使用全文索引进行模糊匹配: - -```sql -SELECT * FROM articles WHERE MATCH (body) AGAINST ('database'); -``` - -注意全文索引如何定义字段的,match中就必须是哪些字段,against中定义需要模糊匹配的字符串,我们用作查找的字符串实际上是被分词之后的结果,如果进行模糊匹配的不是一个词语,那么会查找失败,但是它的效率远高于以下这种写法: - -```sql -SELECT * FROM articles WHERE body like '%database%'; -``` - -### 组合索引 - -组合索引实际上就是将多行捆绑在一起,作为一个索引,它同样支持以上几种索引类型,我们可以在Navicat中进行演示。 - -注意组合索引在进行匹配时,遵循最左原则。 - -我们可以使用`explain`语句(它可以用于分析select语句的执行计划,也就是MySQL到底是如何在执行某条select语句的)来分析查询语句到底有没有通过索引进行匹配。 - -```sql -explain select * from student where name = '小王'; -``` - -得到的结果如下: - -* select_type:查询类型,上面的就是简单查询(SIMPLE) -* table:查询的表 -* type:MySQL决定如何查找对应的记录,效率从高到低:system > const > eq_ref > ref > range > index > all -* possible_keys:执行查询时可能会用到的索引 -* key:实际使用的索引 -* key_len:Mysql在索引里使用的字节数,字段的最大可能长度 -* rows:扫描的行数 -* extra:附加说明 - -### 索引底层原理 - -在了解完了索引的类型之后,我们接着来看看索引是如何实现的。 - -既然我们要通过索引来快速查找内容,那么如何设计索引就是我们的重点内容,因为索引是存储在硬盘上的,跟我们之前使用的HashMap之类的不同,它们都是在内存中的,但是硬盘的读取速度远小于内存的速度,每一次IO操作都会耗费大量的时间,我们也不可能把整个磁盘上的索引全部导入内存,因此我们需要考虑尽可能多的减少IO次数,索引的实现可以依靠两种数据结构,一种是我们在JavaSE阶段已经学习过的Hash表,还有一种就是B-Tree。 - -我们首先来看看哈希表,实际上就是计算Hash值来快速定位: - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fnimg.ws.126.net%2F%3Furl%3Dhttp%3A%2F%2Fdingyue.ws.126.net%2F2020%2F1223%2F2dd7c986j00qlrut10012c000rq00eam.jpg%26thumbnail%3D650x2147483647%26quality%3D80%26type%3Djpg&refer=http%3A%2F%2Fnimg.ws.126.net&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1643975564&t=8c9eabd82da9ac2637b0bdfea57d99bf) - -通过对Key进行散列值计算,我们可以直接得到对应数据的存放位置,它的查询效率能够达到O(1),但是它也存在一定的缺陷: - -* Hash索引仅仅能满足“=”,“in”查询条件,不能使用范围查询。 -* Hash碰撞问题。 -* 不能用部分索引键来搜索,因为组合索引在计算哈希值的时候是一起计算的。 - -那么,既然要解决这些问题,我们还有一种方案就是使用类似于二叉树那样的数据结构来存储索引,但是这样相比使用Hash索引,会牺牲一定的读取速度。 - -但是这里并没有使用二叉树,而是使用了BTree,它是专门为磁盘数据读取设计的一种度为n的查找树: - -* 树中每个结点最多含有m个孩子(m >= 2) -* 除根结点和叶子结点外,其它每个结点至少有[ceil(m / 2)]个孩子。 -* 若根结点不是叶子结点,则至少有2个孩子。 -* 所有叶子结点都出现在同一层。 -* 每个非终端结点中包含有n个键值信息: (P1,K1,P2,K2,P3,......,Kn,Pn+1)。其中: - - 1. Ki (i=1...n)为键值,且键值按顺序升序排序K(i-1)< Ki。 - 2. Pi为指向子树根的结点,且指针P(i)指向的子树中所有结点的键值均小于Ki,但都大于K(i-1)。 - 3. 键值的个数n必须满足: [ceil(m / 2)-1] <= n <= m-1。 - -![img](https://upload-images.jianshu.io/upload_images/12058546-44a71668594a77d9.png?imageMogr2/auto-orient/strip|imageView2/2/w/654) - -比如现在我们要对键值为**10**的记录进行查找,过程如下: - -1. 读取根节点数据(目前进行了一次IO操作) -2. 根据根节点数据进行判断得到10<17,因为P1指向的子树中所有值都是小于17的,所以这时我们将P1指向的节点读取(目前进行了两次IO操作) -3. 再次进行判断,得到8<10<12,因为P2指向的子树中所有的值都是小于12大于8的,所以这时读取P2指向的节点(目前进行了三次IO操作) -4. 成功找到。 - -我们接着来看,虽然BTree能够很好地利用二叉查找树的思想大幅度减少查找次数,但是它的查找效率还是很低,因此它的优化版本B+Tree诞生了,它拥有更稳定的查询效率和更低的IO读取次数: - -![img](https://upload-images.jianshu.io/upload_images/12058546-2ae10c0ddc8ac9ea.png?imageMogr2/auto-orient/strip|imageView2/2/w/646) - -我们可以发现,它和BTree有一定的区别: - -* 有n棵子树的结点中含有n个键值,BTree只有n-1个。 -* 所有的键值信息只在叶子节点中包含,非叶子节点仅仅保存子节点的最小(或最大)值,和指向叶子节点的指针,这样相比BTree每一个节点在硬盘中存放了更少的内容(没有键值信息了) -* 所有叶子节点都有一个根据大小顺序指向下一个叶子节点的指针Q,本质上数据就是一个链表。 - -这样,读取IO的时间相比BTree就减少了很多,并且查询任何键值信息都需要完整地走到叶子节点,保证了查询的IO读取次数一致。因此MySQL默认选择B+Tree作为索引的存储数据结构。 - -这是MyISAM存储引擎下的B+Tree实现: - -![img](https://upload-images.jianshu.io/upload_images/12058546-316168444236022b.png?imageMogr2/auto-orient/strip|imageView2/2/w/664) - -这是InnoDB存储引擎下的B+Tree实现: - -![img](https://upload-images.jianshu.io/upload_images/12058546-0da96cb9de1ff1c3.png?imageMogr2/auto-orient/strip|imageView2/2/w/543) - -![img](https://upload-images.jianshu.io/upload_images/12058546-8cb0dbfd433253b4.png?imageMogr2/auto-orient/strip|imageView2/2/w/543) - -InnoDB与MyISAM实现的不同之处: - -* 数据本身就是索引的一部分(所以这里建议主键使用自增) -* 非主键索引的数据实际上存储的是对应记录的主键值(因此InnoDB必须有主键,若没有也会自动查找替代) - -*** - -## 锁机制 - -在JavaSE的学习中,我们在多线程板块首次用到了锁机制,当我们对某个方法或是某个代码块加锁后,除非锁的持有者释放当前的锁,否则其他线程无法进入此方法或是代码块,我们可以利用锁机制来保证多线程之间的安全性。 - -在MySQL中,就很容易出现多线程同时操作表中数据的情况,如果要避免潜在的并发问题,那么我们可以使用之前讲解的事务隔离级别来处理,而事务隔离中利用了锁机制。 - -- 读未提交(Read Uncommitted):能够读取到其他事务中未提交的内容,存在脏读问题。 -- 读已提交(Read Committed RC):只能读取其他事务已经提交的内容,存在不可重复读问题。 -- 可重复读(Repeated Read RR):在读取某行后不允许其他事务操作此行,直到事务结束,但是依然存在幻读问题。 -- 串行读(Serializable):一个事务的开始必须等待另一个事务的完成。 - -我们可以切换隔离级别分别演示一下: - -```sql -set session transaction isolation level read uncommitted; -``` - -在RR级别下,MySQL在一定程度上解决了幻读问题: - -* 在快照读(不加锁)读情况下,mysql通过mvcc来避免幻读。 -* 在当前读(加锁)读情况下,mysql通过next-key来避免幻读。 - -> **`MVCC`**,全称 `Multi-Version Concurrency Control` ,即多版本并发控制。MVCC 是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问,在编程语言中实现事务内存。 - -### 读锁和写锁 - -从对数据的操作类型上来说,锁分为读锁和写锁: - -* **读锁:**也叫共享锁,当一个事务添加了读锁后,其他的事务也可以添加读锁或是读取数据,但是不能进行写操作,只能等到所有的读锁全部释放。 -* **写锁:**也叫排他锁,当一个事务添加了写锁后,其他事务不能读不能写也不能添加任何锁,只能等待当前事务释放锁。 - -### 全局锁、表锁和行锁 - -从锁的作用范围上划分,分为全局锁、表锁和行锁: - -* **全局锁:**锁作用于全局,整个数据库的所有操作全部受到锁限制。 -* **表锁:**锁作用于整个表,所有对表的操作都会收到锁限制。 -* **行锁:**锁作用于表中的某一行,只会通过锁限制对某一行的操作(仅InnoDB支持) - -#### 全局锁 - -我们首先来看全局锁,它作用于整个数据库,我们可以使用以下命令来开启读全局锁: - -```sql -flush tables with read lock; -``` - -开启后,整个数据库被上读锁,我们只能去读取数据,但是不允许进行写操作(包括更新、插入、删除等)一旦执行写操作,会被阻塞,直到锁被释放,我们可以使用以下命令来解锁: - -```sql -unlock tables; -``` - -除了手动释放锁之外,当我们的会话结束后,锁也会被自动释放。 - -#### 表锁 - -表锁作用于某一张表,也是MyISAM和InnoDB存储引擎支持的方式,我们可以使用以下命令来为表添加锁: - -```sql -lock table 表名称 read/write; -``` - -在我们为表添加写锁后,我们发现其他地方是无法访问此表的,一律都被阻塞。 - -#### 行锁 - -表锁的作用范围太广了,如果我们仅仅只是对某一行进行操作,那么大可不必对整个表进行加锁,因此`InnoDB`支持了行锁,我们可以使用以下命令来对某一行进行加锁: - -```sql --- 添加读锁(共享锁) -select * from ... lock in share mode; --- 添加写锁(排他锁) -select * from ... for update; -``` - -使用InnoDB的情况下,在执行更新、删除、插入操作时,数据库也会自动为所涉及的行添加写锁(排他锁),直到事务提交时,才会释放锁,执行普通的查询操作时,不会添加任何锁。使用MyISAM的情况下,在执行更新、删除、插入操作时,数据库会对涉及的表添加写锁,在执行查询操作时,数据库会对涉及的表添加读锁。 - -**提问:**当我们不使用id进行选择,行锁会发生什么变化?(行锁升级) - -### 记录锁、间隙锁和临键锁 - -我们知道InnoDB支持使用行锁,但是行锁比较复杂,它可以继续分为多个类型。 - -#### 记录锁 - -(Record Locks)记录锁, 仅仅锁住索引记录的一行,在单条索引记录上加锁。Record lock锁住的永远是索引,而非记录本身,即使该表上没有任何索引,那么innodb会在后台创建一个隐藏的聚集主键索引,那么锁住的就是这个隐藏的聚集主键索引。所以说当一条sql没有走任何索引时,那么将会在每一条聚合索引后面加写锁,这个类似于表锁,但原理上和表锁应该是完全不同的。 - -#### 间隙锁 - -(Gap Locks)仅仅锁住一个索引区间(开区间,不包括双端端点)。在索引记录之间的间隙中加锁,或者是在某一条索引记录之前或者之后加锁,并不包括该索引记录本身。比如在 1、2中,间隙锁的可能值有 (-∞, 1),(1, 2),(2, +∞),间隙锁可用于防止幻读,保证索引间的不会被插入数据。 - -#### 临键锁 - -(Next-Key Locks)Record lock + Gap lock,左开右闭区间。默认情况下,`InnoDB`正是使用Next-key Locks来锁定记录(如select … for update语句)它还会根据场景进行灵活变换: - -| 场景 | 转换 | -| :----------------------------------------- | ------------------------------ | -| 使用唯一索引进行精确匹配,但表中不存在记录 | 自动转换为 Gap Locks | -| 使用唯一索引进行精确匹配,且表中存在记录 | 自动转换为 Record Locks | -| 使用非唯一索引进行精确匹配 | 不转换 | -| 使用唯一索引进行范围匹配 | 不转换,但是只锁上界,不锁下界 | - -https://zhuanlan.zhihu.com/p/48269420 diff --git a/青空笔记/JavaWeb笔记/JavaWeb笔记(一).md b/青空笔记/JavaWeb笔记/JavaWeb笔记(一).md deleted file mode 100644 index 1e66e7c..0000000 --- a/青空笔记/JavaWeb笔记/JavaWeb笔记(一).md +++ /dev/null @@ -1,314 +0,0 @@ -# Java网络编程 - -在JavaSE阶段,我们学习了I/O流,既然I/O流如此强大,那么能否跨越不同的主机进行I/O操作呢?这就要提到Java的网络编程了。 - -**注意:**本章会涉及到`计算机网络`相关内容(只会讲解大致内容,不会完整的讲解计算机网络知识) - -## 计算机网络基础 - -利用通信线路和通信设备,将地理位置不同的、功能独立的多台计算机互连起来,以功能完善的网络软件来实现资源共享和信息传递,就构成了计算机网络系统。 - -![img](https://pics2.baidu.com/feed/503d269759ee3d6d1356774cd59afe244e4ade3c.jpeg?token=f256bfddbd14418f8f3d3d4964ed4cf5) - -比如我们家里的路由器,通过将我们的设备(手机、平板、电脑、电视剧)连接到路由器,来实现对互联网的访问。实际上,我们的路由器连接在互联网上,而我们的设备又连接了路由器,这样我们的设备就可以通过路由器访问到互联网了。通过网络,我们可以直接访问互联网上的另一台主机,比如我们要把QQ的消息发送给我们的朋友,或是通过远程桌面管理来操作另一台电脑,也可以是连接本地网络上的打印机。 - -既然我们可以通过网络访问其他计算机,那么如何区别不同的计算机呢?通过IP地址,我们就可以区分不同的计算机了: - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg.it610.com%2Fimage%2Finfo5%2Facf4321f34144b69811bdde9bec045c8.jpg&refer=http%3A%2F%2Fimg.it610.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1637820816&t=955be58edb486e7a69cdea2381714252) - -每一台电脑在同一个网络上都有一个自己的IP地址,用于区别于其他的电脑,我们可以通过对方主机的IP地址对其进行访问。那么我手机连接的移动流量,能访问到连接家里路由器的电脑吗?(不能,因为他们不属于同一个网络) - -而我们的电脑上可能运行着大量的程序,每一个程序可能都需要通过网络来访问其他计算机,那这时该如何区分呢?我们可以通过端口号来区分: - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg2020.cnblogs.com%2Fblog%2F2068098%2F202008%2F2068098-20200808153937940-609503998.png&refer=http%3A%2F%2Fimg2020.cnblogs.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1637821431&t=9dfd5db6c6f75f843d1663f54b2ccb6c) - -因此,我们一般看到的是这样的:`192.168.0.11:8080`,通过`IP:端口`的形式来访问目标主机上的一个应用程序服务。注意端口号只能是0-65535之间的值! - -IP地址分为IPv4和IPv6,IPv4类似于`192.168.0.11`,我们上面提到的例子都是使用的IPv4,它一共有四组数字,每组数字占8个bit位,IPv4地址`0.0.0.0`表示为2进制就是:00000000.00000000.00000000.00000000,共32个bit,最大为`255.255.255.255`,实际上,IPv4能够表示的所有地址,早就已经被用完了。IPv6能够保存128个bit位,因此它也可以表示更多的IP地址,一个IPv6地址看起来像这样:`1030::C9B4:FF12:48AA:1A2B`,目前也正在向IPv6的阶段过度。 - -TCP和UDP是两种不同的传输层协议: - -* TCP:当一台计算机想要与另一台计算机通讯时,两台计算机之间的通信需要畅通且可靠(会进行三次握手,断开也会进行四次挥手),这样才能保证正确收发数据,因此TCP更适合一些可靠的数据传输场景。 -* UDP:它是一种无连接协议,数据想发就发,而且不会建立可靠传输,也就是说传输过程中有可能会导致部分数据丢失,但是它比TCP传输更加简单高效,适合视频直播之类的。 - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2F5b0988e595225.cdn.sohucs.com%2Fq_70%2Cc_zoom%2Cw_640%2Fimages%2F20200212%2F0f3d7f77442643c099dddbb159a183f6.jpeg&refer=http%3A%2F%2F5b0988e595225.cdn.sohucs.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1637824557&t=b5d5cb0c013ae945e838e88a345edb9c) - -*** - -## 了解Socket技术 - -通过Socket技术(它是计算机之间进行**通信**的**一种约定**或一种方式),我们就可以实现两台计算机之间的通信,Socket也被翻译为`套接字`,是操作系统底层提供的一项通信技术,它支持TCP和UDP。而Java就对socket底层支持进行了一套完整的封装,我们可以通过Java来实现Socket通信。 - -要实现Socket通信,我们必须创建一个数据发送者和一个数据接收者,也就是客户端和服务端,我们需要提前启动服务端,来等待客户端的连接,而客户端只需要随时启动去连接服务端即可! - -```java -//服务端 -public static void main(String[] args) { - try(ServerSocket server = new ServerSocket(8080)){ //将服务端创建在端口8080上 - System.out.println("正在等待客户端连接..."); - Socket socket = server.accept(); //当没有客户端连接时,线程会阻塞,直到有客户端连接为止 - System.out.println("客户端已连接,IP地址为:"+socket.getInetAddress().getHostAddress()); - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -```java -//客户端 -public static void main(String[] args) { - try (Socket socket = new Socket("localhost", 8080)){ - System.out.println("已连接到服务端!"); - }catch (IOException e){ - System.out.println("服务端连接失败!"); - e.printStackTrace(); - } -} -``` - -实际上它就是一个TCP连接的建立过程: - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fwww.reader8.cn%2Fuploadfile%2Fjiaocheng%2F201401101%2F3039%2F2014013015391315977.jpg&refer=http%3A%2F%2Fwww.reader8.cn&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1637838562&t=a22f860adb01fda478ecb76f34c34252) - -一旦TCP连接建立,服务端和客户端之间就可以相互发送数据,直到客户端主动关闭连接。当然,服务端不仅仅只可以让一个客户端进行连接,我们可以尝试让服务端一直运行来不断接受客户端的连接: - -```java -public static void main(String[] args) { - try(ServerSocket server = new ServerSocket(8080)){ //将服务端创建在端口8080上 - System.out.println("正在等待客户端连接..."); - while (true){ //无限循环等待客户端连接 - Socket socket = server.accept(); - System.out.println("客户端已连接,IP地址为:"+socket.getInetAddress().getHostAddress()); - } - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -现在我们就可以多次去连接此服务端了。 - -## 使用Socket进行数据传输 - -通过Socket对象,我们就可以获取到对应的I/O流进行网络数据传输: - -```java -public static void main(String[] args) { - try (Socket socket = new Socket("localhost", 8080); - Scanner scanner = new Scanner(System.in)){ - System.out.println("已连接到服务端!"); - OutputStream stream = socket.getOutputStream(); - OutputStreamWriter writer = new OutputStreamWriter(stream); //通过转换流来帮助我们快速写入内容 - System.out.println("请输入要发送给服务端的内容:"); - String text = scanner.nextLine(); - writer.write(text+'\n'); //因为对方是readLine()这里加个换行符 - writer.flush(); - System.out.println("数据已发送:"+text); - }catch (IOException e){ - System.out.println("服务端连接失败!"); - e.printStackTrace(); - }finally { - System.out.println("客户端断开连接!"); - } - } -} -``` - -```java -public static void main(String[] args) { - try(ServerSocket server = new ServerSocket(8080)){ //将服务端创建在端口8080上 - System.out.println("正在等待客户端连接..."); - Socket socket = server.accept(); - System.out.println("客户端已连接,IP地址为:"+socket.getInetAddress().getHostAddress()); - BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); //通过 - System.out.print("接收到客户端数据:"); - System.out.println(reader.readLine()); - socket.close(); //和服务端TCP连接完成之后,记得关闭socket - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -同理,既然服务端可以读取客户端的内容,客户端也可以在发送后等待服务端给予响应: - -```java -public static void main(String[] args) { - try (Socket socket = new Socket("localhost", 8080); - Scanner scanner = new Scanner(System.in)){ - System.out.println("已连接到服务端!"); - OutputStream stream = socket.getOutputStream(); - OutputStreamWriter writer = new OutputStreamWriter(stream); //通过转换流来帮助我们快速写入内容 - System.out.println("请输入要发送给服务端的内容:"); - String text = scanner.nextLine(); - writer.write(text+'\n'); //因为对方是readLine()这里加个换行符 - writer.flush(); - System.out.println("数据已发送:"+text); - BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); - System.out.println("收到服务器返回:"+reader.readLine()); - }catch (IOException e){ - System.out.println("服务端连接失败!"); - e.printStackTrace(); - }finally { - System.out.println("客户端断开连接!"); - } -} -``` - -```java -public static void main(String[] args) { - try(ServerSocket server = new ServerSocket(8080)){ //将服务端创建在端口8080上 - System.out.println("正在等待客户端连接..."); - Socket socket = server.accept(); - System.out.println("客户端已连接,IP地址为:"+socket.getInetAddress().getHostAddress()); - BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); //通过 - System.out.print("接收到客户端数据:"); - System.out.println(reader.readLine()); - OutputStreamWriter writer = new OutputStreamWriter(socket.getOutputStream()); - writer.write("已收到!"); - writer.flush(); - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -我们可以手动关闭单向的流: - -```java -socket.shutdownOutput(); //关闭输出方向的流 -socket.shutdownInput(); //关闭输入方向的流 -``` - -如果我们不希望服务端等待太长的时间,我们可以通过调用`setSoTimeout()`方法来设定IO超时时间: - -```java -socket.setSoTimeout(3000); -``` - -当超过设定时间都依然没有收到客户端或是服务端的数据时,会抛出异常: - -```java -java.net.SocketTimeoutException: Read timed out - at java.net.SocketInputStream.socketRead0(Native Method) - at java.net.SocketInputStream.socketRead(SocketInputStream.java:116) - at java.net.SocketInputStream.read(SocketInputStream.java:171) - at java.net.SocketInputStream.read(SocketInputStream.java:141) - at sun.nio.cs.StreamDecoder.readBytes(StreamDecoder.java:284) - at sun.nio.cs.StreamDecoder.implRead(StreamDecoder.java:326) - at sun.nio.cs.StreamDecoder.read(StreamDecoder.java:178) - at java.io.InputStreamReader.read(InputStreamReader.java:184) - at java.io.BufferedReader.fill(BufferedReader.java:161) - at java.io.BufferedReader.readLine(BufferedReader.java:324) - at java.io.BufferedReader.readLine(BufferedReader.java:389) - at com.test.Main.main(Main.java:41) -``` - -我们之前使用的都是通过构造方法直接连接服务端,那么是否可以等到我们想要的时候再去连接呢? - -```java -try (Socket socket = new Socket(); //调用无参构造不会自动连接 - Scanner scanner = new Scanner(System.in)){ - socket.connect(new InetSocketAddress("localhost", 8080), 1000); //手动调用connect方法进行连接 -``` - -如果连接的双方发生意外而通知不到对方,导致一方还持有连接,这样就会占用资源,因此我们可以使用`setKeepAlive()`方法来防止此类情况发生: - -```java -socket.setKeepAlive(true); -``` - -当客户端连接后,如果设置了keeplive为 true,当对方没有发送任何数据过来,超过一个时间(看系统内核参数配置),那么我们这边会发送一个ack探测包发到对方,探测双方的TCP/IP连接是否有效。 - -TCP在传输过程中,实际上会有一个缓冲区用于数据的发送和接收: - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fpic1.zhimg.com%2Fv2-72f5d4bebca1242a163cbd1ebff3cdbc_b.jpg&refer=http%3A%2F%2Fpic1.zhimg.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1637902504&t=2b8dead347f9d2a5bd72e703fc11b987) - -此缓冲区大小为:8192,我们可以手动调整其大小来优化传输效率: - -```java -socket.setReceiveBufferSize(25565); //TCP接收缓冲区 -socket.setSendBufferSize(25565); //TCP发送缓冲区 -``` - -## 使用Socket传输文件 - -既然Socket为我们提供了IO流便于数据传输,那么我们就可以轻松地实现文件传输了。 - -## 使用浏览器访问Socket服务器 - -在了解了如何使用Socket传输文件后,我们来看看,浏览器是如何向服务器发起请求的: - -```java -public static void main(String[] args) { - try(ServerSocket server = new ServerSocket(8080)){ //将服务端创建在端口8080上 - System.out.println("正在等待客户端连接..."); - Socket socket = server.accept(); - System.out.println("客户端已连接,IP地址为:"+socket.getInetAddress().getHostAddress()); - InputStream in = socket.getInputStream(); //通过 - System.out.println("接收到客户端数据:"); - while (true){ - int i = in.read(); - if(i == -1) break; - System.out.print((char) i); - } - }catch (Exception e){ - e.printStackTrace(); - } - } -``` - -我们现在打开浏览器,输入http://localhost:8080或是http://127.0.0.1:8080/,来连接我们本地开放的服务器。 - -我们发现浏览器是无法打开这个链接的,但是我们服务端却收到了不少的信息: - -```properties -GET / HTTP/1.1 -Host: 127.0.0.1:8080 -Connection: keep-alive -Cache-Control: max-age=0 -sec-ch-ua: "Chromium";v="94", "Google Chrome";v="94", ";Not A Brand";v="99" -sec-ch-ua-mobile: ?0 -sec-ch-ua-platform: "macOS" -Upgrade-Insecure-Requests: 1 -User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36 -Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 -Sec-Fetch-Site: none -Sec-Fetch-Mode: navigate -Sec-Fetch-User: ?1 -Sec-Fetch-Dest: document -Accept-Encoding: gzip, deflate, br -Accept-Language: zh-CN,zh;q=0.9,und;q=0.8,en;q=0.7 -``` - -实际上这些内容都是Http协议规定的请求头内容。HTTP是一种应用层协议,全称为超文本传输协议,它本质也是基于TCP协议进行数据传输,因此我们的服务端能够读取HTTP请求。但是Http协议并不会保持长连接,在得到我们响应的数据后会立即关闭TCP连接。 - -既然使用的是Http连接,如果我们的服务器要支持响应HTTP请求,那么就需要按照HTTP协议的规则,返回一个规范的响应文本,首先是响应头,它至少要包含一个响应码: - -```properties -HTTP/1.1 200 Accpeted -``` - -然后就是响应内容(注意一定要换行再写),我们尝试来编写一下支持HTTP协议的响应内容: - -```java -public static void main(String[] args) { - try(ServerSocket server = new ServerSocket(8080)){ //将服务端创建在端口8080上 - System.out.println("正在等待客户端连接..."); - Socket socket = server.accept(); - System.out.println("客户端已连接,IP地址为:"+socket.getInetAddress().getHostAddress()); - BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); //通过 - System.out.println("接收到客户端数据:"); - while (reader.ready()) System.out.println(reader.readLine()); //ready是判断当前流中是否还有可读内容 - OutputStreamWriter writer = new OutputStreamWriter(socket.getOutputStream()); - writer.write("HTTP/1.1 200 Accepted\r\n"); //200是响应码,Http协议规定200为接受请求,400为错误的请求,404为找不到此资源(不止这些,还有很多) - writer.write("\r\n"); //在请求头写完之后还要进行一次换行,然后写入我们的响应实体(会在浏览器上展示的内容) - writer.write("lbwnb!"); - writer.flush(); - }catch (Exception e){ - e.printStackTrace(); - } -} -``` - -我们可以打开浏览器的开发者模式(这里推荐使用Chrome/Edge浏览器,按下F12即可打开),我们来观察一下浏览器的实际请求过程。 diff --git a/青空笔记/JavaWeb笔记/JavaWeb笔记(三).md b/青空笔记/JavaWeb笔记/JavaWeb笔记(三).md deleted file mode 100644 index 2c563b0..0000000 --- a/青空笔记/JavaWeb笔记/JavaWeb笔记(三).md +++ /dev/null @@ -1,2683 +0,0 @@ -# Java与数据库 - -通过Java如何去使用数据库来帮助我们存储数据呢,这将是本章节讨论的重点。 - -## 初识JDBC - -JDBC是什么?JDBC英文名为:Java Data Base Connectivity(Java数据库连接),官方解释它是Java编程语言和广泛的数据库之间独立于数据库的连接标准的Java API,根本上说JDBC是一种规范,它提供的接口,一套完整的,允许便捷式访问底层数据库。可以用JAVA来写不同类型的可执行文件:JAVA应用程序、JAVA Applets、Java Servlet、JSP等,不同的可执行文件都能通过JDBC访问数据库,又兼备存储的优势。简单说它就是Java与数据库的连接的桥梁或者插件,用Java代码就能操作数据库的增删改查、存储过程、事务等。 - -我们可以发现,JDK自带了一个`java.sql`包,而这里面就定义了大量的接口,不同类型的数据库,都可以通过实现此接口,编写适用于自己数据库的实现类。而不同的数据库厂商实现的这套标准,我们称为`数据库驱动`。 - -### 准备工作 - -那么我们首先来进行一些准备工作,以便开始JDBC的学习: - -* 将idea连接到我们的数据库,以便以后调试。 -* 将mysql驱动jar依赖导入到项目中(推荐6.0版本以上,这里用到是8.0) -* 向Jetbrians申请一个学生/教师授权,用于激活idea终极版(进行JavaWeb开发需要用到,一般申请需要3-7天时间审核)不是大学生的话...emmm...懂的都懂。 -* 教育授权申请地址:https://www.jetbrains.com/shop/eform/students - -一个Java程序并不是一个人的战斗,我们可以在别人开发的基础上继续向上开发,其他的开发者可以将自己编写的Java代码打包为`jar`,我们只需要导入这个`jar`作为依赖,即可直接使用别人的代码,就像我们直接去使用JDK提供的类一样。 - -### 使用JDBC连接数据库 - -**注意:**6.0版本以上,不用手动加载驱动,我们直接使用即可! - -```java -//1. 通过DriverManager来获得数据库连接 -try (Connection connection = DriverManager.getConnection("连接URL","用户名","密码"); - //2. 创建一个用于执行SQL的Statement对象 - Statement statement = connection.createStatement()){ //注意前两步都放在try()中,因为在最后需要释放资源! - //3. 执行SQL语句,并得到结果集 - ResultSet set = statement.executeQuery("select * from 表名"); - //4. 查看结果 - while (set.next()){ - ... - } -}catch (SQLException e){ - e.printStackTrace(); -} -//5. 释放资源,try-with-resource语法会自动帮助我们close -``` - -其中,连接的URL如果记不住格式,我们可以打开idea的数据库连接配置,复制一份即可。(其实idea本质也是使用的JDBC,整个idea程序都是由Java编写的,实际上idea就是一个Java程序) - -### 了解DriverManager - -我们首先来了解一下DriverManager是什么东西,它其实就是管理我们的数据库驱动的: - -```java -public static synchronized void registerDriver(java.sql.Driver driver, - DriverAction da) - throws SQLException { - - /* Register the driver if it has not already been added to our list */ - if(driver != null) { - registeredDrivers.addIfAbsent(new DriverInfo(driver, da)); //在刚启动时,mysql实现的驱动会被加载,我们可以断点调试一下。 - } else { - // This is for compatibility with the original DriverManager - throw new NullPointerException(); - } - - println("registerDriver: " + driver); - -} -``` - -我们可以通过调用getConnection()来进行数据库的链接: - -```java -@CallerSensitive -public static Connection getConnection(String url, - String user, String password) throws SQLException { - java.util.Properties info = new java.util.Properties(); - - if (user != null) { - info.put("user", user); - } - if (password != null) { - info.put("password", password); - } - - return (getConnection(url, info, Reflection.getCallerClass())); //内部有实现 -} -``` - -我们可以手动为驱动管理器添加一个日志打印: - -```java -static { - DriverManager.setLogWriter(new PrintWriter(System.out)); //这里直接设定为控制台输出 -} -``` - -现在我们执行的数据库操作日志会在控制台实时打印。 - -### 了解Connection - -Connection是数据库的连接对象,可以通过连接对象来创建一个Statement用于执行SQL语句: - -```java -Statement createStatement() throws SQLException; -``` - -我们发现除了普通的Statement,还存在PreparedStatement: - -```java -PreparedStatement prepareStatement(String sql) - throws SQLException; -``` - -在后面我们会详细介绍PreparedStatement的使用,它能够有效地预防SQL注入式攻击。 - -它还支持事务的处理,也放到后面来详细进行讲解。 - -### 了解Statement - -我们发现,我们之前使用了`executeQuery()`方法来执行`select`语句,此方法返回给我们一个ResultSet对象,查询得到的数据,就存放在ResultSet中! - -Statement除了执行这样的DQL语句外,我们还可以使用`executeUpdate()`方法来执行一个DML或是DDL语句,它会返回一个int类型,表示执行后受影响的行数,可以通过它来判断DML语句是否执行成功。 - -也可以通过`excute()`来执行任意的SQL语句,它会返回一个`boolean`来表示执行结果是一个ResultSet还是一个int,我们可以通过使用`getResultSet()`或是`getUpdateCount()`来获取。 - -### 执行DML操作 - -我们通过几个例子来向数据库中插入数据。 - -### 执行DQL操作 - -执行DQL操作会返回一个ResultSet对象,我们来看看如何从ResultSet中去获取数据: - -```java -//首先要明确,select返回的数据类似于一个excel表格 -while (set.next()){ - //每调用一次next()就会向下移动一行,首次调用会移动到第一行 -} -``` - -我们在移动行数后,就可以通过set中提供的方法,来获取每一列的数据。 - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg-blog.csdnimg.cn%2F202005062358238.png%3Fx-oss-process%3Dimage%2Fwatermark%2Ctype_ZmFuZ3poZW5naGVpdGk%2Cshadow_10%2Ctext_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L1JlZ2lubw%3D%3D%2Csize_16%2Ccolor_FFFFFF%2Ct_70&refer=http%3A%2F%2Fimg-blog.csdnimg.cn&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1638091193&t=bf37a5cb988d0a641d00c7e325d06ce7) - -### 执行批处理操作 - -当我们要执行很多条语句时,可以不用一次一次地提交,而是一口气全部交给数据库处理,这样会节省很多的时间。 - -```java -public static void main(String[] args) throws ClassNotFoundException { - try (Connection connection = DriverManager.getConnection(); - Statement statement = connection.createStatement()){ - - statement.addBatch("insert into user values ('f', 1234)"); - statement.addBatch("insert into user values ('e', 1234)"); //添加每一条批处理语句 - statement.executeBatch(); //一起执行 - - }catch (SQLException e){ - e.printStackTrace(); - } -} -``` - -### 将查询结果映射为对象 - -既然我们现在可以从数据库中获取数据了,那么现在就可以将这些数据转换为一个类来进行操作,首先定义我们的实体类: - -```java -public class Student { - Integer sid; - String name; - String sex; - - public Student(Integer sid, String name, String sex) { - this.sid = sid; - this.name = name; - this.sex = sex; - } - - public void say(){ - System.out.println("我叫:"+name+",学号为:"+sid+",我的性别是:"+sex); - } -} -``` - -现在我们来进行一个转换: - -```java -while (set.next()){ - Student student = new Student(set.getInt(1), set.getString(2), set.getString(3)); - student.say(); -} -``` - -**注意:**列的下标是从1开始的。 - -我们也可以利用反射机制来将查询结果映射为对象,使用反射的好处是,无论什么类型都可以通过我们的方法来进行实体类型映射: - -```java -private static T convert(ResultSet set, Class clazz){ - try { - Constructor constructor = clazz.getConstructor(clazz.getConstructors()[0].getParameterTypes()); //默认获取第一个构造方法 - Class[] param = constructor.getParameterTypes(); //获取参数列表 - Object[] object = new Object[param.length]; //存放参数 - for (int i = 0; i < param.length; i++) { //是从1开始的 - object[i] = set.getObject(i+1); - if(object[i].getClass() != param[i]) - throw new SQLException("错误的类型转换:"+object[i].getClass()+" -> "+param[i]); - } - return constructor.newInstance(object); - } catch (ReflectiveOperationException | SQLException e) { - e.printStackTrace(); - return null; - } -} -``` - -现在我们就可以通过我们的方法来将查询结果转换为一个对象了: - -```java -while (set.next()){ - Student student = convert(set, Student.class); - if(student != null) student.say(); -} -``` - -实际上,在后面我们会学习Mybatis框架,它对JDBC进行了深层次的封装,而它就进行类似上面反射的操作来便于我们对数据库数据与实体类的转换。 - -### 实现登陆与SQL注入攻击 - -在使用之前,我们先来看看如果我们想模拟登陆一个用户,我们该怎么去写: - -```java -try (Connection connection = DriverManager.getConnection("URL","用户名","密码"); - Statement statement = connection.createStatement(); - Scanner scanner = new Scanner(System.in)){ - ResultSet res = statement.executeQuery("select * from user where username='"+scanner.nextLine()+"'and pwd='"+scanner.nextLine()+"';"); - while (res.next()){ - String username = res.getString(1); - System.out.println(username+" 登陆成功!"); - } -}catch (SQLException e){ - e.printStackTrace(); -} -``` - -用户可以通过自己输入用户名和密码来登陆,乍一看好像没啥问题,那如果我输入的是以下内容呢: - -```sql -Test -1111' or 1=1; -- -# Test 登陆成功! -``` - -1=1一定是true,那么我们原本的SQL语句会变为: - -```sql -select * from user where username='Test' and pwd='1111' or 1=1; -- ' -``` - -我们发现,如果允许这样的数据插入,那么我们原有的SQL语句结构就遭到了破坏,使得用户能够随意登陆别人的账号。因此我们可能需要限制用户的输入来防止用户输入一些SQL语句关键字,但是关键字非常多,这并不是解决问题的最好办法。 - -### 使用PreparedStatement - -我们发现,如果单纯地使用Statement来执行SQL命令,会存在严重的SQL注入攻击漏洞!而这种问题,我们可以使用PreparedStatement来解决: - -```java -public static void main(String[] args) throws ClassNotFoundException { - try (Connection connection = DriverManager.getConnection("URL","用户名","密码"); - PreparedStatement statement = connection.prepareStatement("select * from user where username= ? and pwd=?;"); - Scanner scanner = new Scanner(System.in)){ - - statement.setString(1, scanner.nextLine()); - statement.setString(2, scanner.nextLine()); - System.out.println(statement); //打印查看一下最终执行的 - ResultSet res = statement.executeQuery(); - while (res.next()){ - String username = res.getString(1); - System.out.println(username+" 登陆成功!"); - } - }catch (SQLException e){ - e.printStackTrace(); - } -} -``` - -我们发现,我们需要提前给到PreparedStatement一个SQL语句,并且使用`?`作为占位符,它会预编译一个SQL语句,通过直接将我们的内容进行替换的方式来填写数据。使用这种方式,我们之前的例子就失效了!我们来看看实际执行的SQL语句是什么: - -``` -com.mysql.cj.jdbc.ClientPreparedStatement: select * from user where username= 'Test' and pwd='123456'' or 1=1; -- '; -``` - -我们发现,我们输入的参数一旦出现`'`时,会被变为转义形式`\'`,而最外层有一个真正的`'`来将我们输入的内容进行包裹,因此它能够有效地防止SQL注入攻击! - -### 管理事务 - -JDBC默认的事务处理行为是自动提交,所以前面我们执行一个SQL语句就会被直接提交(相当于没有启动事务),所以JDBC需要进行事务管理时,首先要通过Connection对象调用setAutoCommit(false) 方法, 将SQL语句的提交(commit)由驱动程序转交给应用程序负责。 - -```java -con.setAutoCommit(); //关闭自动提交后相当于开启事务。 -// SQL语句 -// SQL语句 -// SQL语句 -con.commit();或 con.rollback(); -``` - -一旦关闭自动提交,那么现在执行所有的操作如果在最后不进行`commit()`来提交事务的话,那么所有的操作都会丢失,只有提交之后,所有的操作才会被保存!也可以使用`rollback()`来手动回滚之前的全部操作! - -```java -public static void main(String[] args) throws ClassNotFoundException { - try (Connection connection = DriverManager.getConnection("URL","用户名","密码"); - Statement statement = connection.createStatement()){ - - connection.setAutoCommit(false); //关闭自动提交,现在将变为我们手动提交 - statement.executeUpdate("insert into user values ('a', 1234)"); - statement.executeUpdate("insert into user values ('b', 1234)"); - statement.executeUpdate("insert into user values ('c', 1234)"); - - connection.commit(); //如果前面任何操作出现异常,将不会执行commit(),之前的操作也就不会生效 - }catch (SQLException e){ - e.printStackTrace(); - } -} -``` - -我们来接着尝试一下使用回滚操作: - -```java -public static void main(String[] args) throws ClassNotFoundException { - try (Connection connection = DriverManager.getConnection("URL","用户名","密码"); - Statement statement = connection.createStatement()){ - - connection.setAutoCommit(false); //关闭自动提交,现在将变为我们手动提交 - statement.executeUpdate("insert into user values ('a', 1234)"); - statement.executeUpdate("insert into user values ('b', 1234)"); - - connection.rollback(); //回滚,撤销前面全部操作 - - statement.executeUpdate("insert into user values ('c', 1234)"); - - connection.commit(); //提交事务(注意,回滚之前的内容都没了) - - }catch (SQLException e){ - e.printStackTrace(); - } -} -``` - -同样的,我们也可以去创建一个回滚点来实现定点回滚: - -```java -public static void main(String[] args) throws ClassNotFoundException { - try (Connection connection = DriverManager.getConnection("URL","用户名","密码"); - Statement statement = connection.createStatement()){ - - connection.setAutoCommit(false); //关闭自动提交,现在将变为我们手动提交 - statement.executeUpdate("insert into user values ('a', 1234)"); - - Savepoint savepoint = connection.setSavepoint(); //创建回滚点 - statement.executeUpdate("insert into user values ('b', 1234)"); - - connection.rollback(savepoint); //回滚到回滚点,撤销前面全部操作 - - statement.executeUpdate("insert into user values ('c', 1234)"); - - connection.commit(); //提交事务(注意,回滚之前的内容都没了) - - }catch (SQLException e){ - e.printStackTrace(); - } -} -``` - -通过开启事务,我们就可以更加谨慎地进行一些操作了,如果我们想从事务模式切换为原有的自动提交模式,我们可以直接将其设置回去: - -```java -public static void main(String[] args) throws ClassNotFoundException { - try (Connection connection = DriverManager.getConnection("URL","用户名","密码"); - Statement statement = connection.createStatement()){ - - connection.setAutoCommit(false); //关闭自动提交,现在将变为我们手动提交 - statement.executeUpdate("insert into user values ('a', 1234)"); - connection.setAutoCommit(true); //重新开启自动提交,开启时把之前的事务模式下的内容给提交了 - statement.executeUpdate("insert into user values ('d', 1234)"); - //没有commit也成功了! - }catch (SQLException e){ - e.printStackTrace(); - } - -``` - -通过学习JDBC,我们现在就可以通过Java来访问和操作我们的数据库了!为了更好地衔接,我们还会接着讲解主流持久层框架——Mybatis,加深JDBC的记忆。 - -*** - -## 使用Lombok - -我们发现,在以往编写项目时,尤其是在类进行类内部成员字段封装时,需要编写大量的get/set方法,这不仅使得我们类定义中充满了get和set方法,同时如果字段名称发生改变,又要挨个进行修改,甚至当字段变得很多时,构造方法的编写会非常麻烦! - -通过使用Lombok(小辣椒)就可以解决这样的问题! - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Finews.gtimg.com%2Fnewsapp_bt%2F0%2F14004711543%2F1000&refer=http%3A%2F%2Finews.gtimg.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1638080575&t=91a3937a42d14fe8129b3761bbdef82c) - -我们来看看,使用原生方式和小辣椒方式编写类的区别,首先是传统方式: - -```java -public class Student { - private Integer sid; - private String name; - private String sex; - - public Student(Integer sid, String name, String sex) { - this.sid = sid; - this.name = name; - this.sex = sex; - } - - public Integer getSid() { //长! - return sid; - } - - public void setSid(Integer sid) { //到! - this.sid = sid; - } - - public String getName() { //爆! - return name; - } - - public void setName(String name) { //炸! - this.name = name; - } - - public String getSex() { - return sex; - } - - public void setSex(String sex) { - this.sex = sex; - } -} -``` - -而使用Lombok之后: - -```java -@Getter -@Setter -@AllArgsConstructor -public class Student { - private Integer sid; - private String name; - private String sex; -} -``` - -我们发现,使用Lombok之后,只需要添加几个注解,就能够解决掉我们之前长长的一串代码! - -### 配置Lombok - -* 首先我们需要导入Lombok的jar依赖,和jdbc依赖是一样的,放在项目目录下直接导入就行了。可以在这里进行下载:https://projectlombok.org/download -* 然后我们要安装一下Lombok插件,由于IDEA默认都安装了Lombok的插件,因此直接导入依赖后就可以使用了。 -* 重启IDEA - -Lombok是一种插件化注解API,是通过添加注解来实现的,然后在javac进行编译的时候,进行处理。 - -Java的编译过程可以分成三个阶段: - -![img](https://imgconvert.csdnimg.cn/aHR0cDovL29wZW5qZGsuamF2YS5uZXQvZ3JvdXBzL2NvbXBpbGVyL2RvYy9jb21waWxhdGlvbi1vdmVydmlldy9qYXZhYy1mbG93LnBuZw?x-oss-process=image/format,png) - -1. 所有源文件会被解析成语法树。 -2. 调用注解处理器。如果注解处理器产生了新的源文件,新文件也要进行编译。 -3. 最后,语法树会被分析并转化成类文件。 - -实际上在上述的第二阶段,会执行*[lombok.core.AnnotationProcessor](https://github.com/rzwitserloot/lombok/blob/master/src/core/lombok/core/AnnotationProcessor.java)*,它所做的工作就是我们上面所说的,修改语法树。 - -### 使用Lombok - -我们通过实战来演示一下Lombok的实用注解: - -* 我们通过添加`@Getter`和`@Setter`来为当前类的所有字段生成get/set方法,他们可以添加到类或是字段上,注意静态字段不会生成,final字段无法生成set方法。 - * 我们还可以使用@Accessors来控制生成Getter和Setter的样式。 -* 我们通过添加`@ToString`来为当前类生成预设的toString方法。 -* 我们可以通过添加`@EqualsAndHashCode`来快速生成比较和哈希值方法。 -* 我们可以通过添加`@AllArgsConstructor`和`@NoArgsConstructor`来快速生成全参构造和无参构造。 -* 我们可以添加`@RequiredArgsConstructor`来快速生成参数只包含`final`或被标记为`@NonNull`的成员字段。 -* 使用`@Data`能代表`@Setter`、`@Getter`、`@RequiredArgsConstructor`、`@ToString`、`@EqualsAndHashCode`全部注解。 - * 一旦使用`@Data`就不建议此类有继承关系,因为`equal`方法可能不符合预期结果(尤其是仅比较子类属性)。 -* 使用`@Value`与`@Data`类似,但是并不会生成setter并且成员属性都是final的。 -* 使用`@SneakyThrows`来自动生成try-catch代码块。 -* 使用`@Cleanup`作用与局部变量,在最后自动调用其`close()`方法(可以自由更换) -* 使用`@Builder`来快速生成建造者模式。 - * 通过使用`@Builder.Default`来指定默认值。 - * 通过使用`@Builder.ObtainVia`来指定默认值的获取方式。 - -*** - -## 认识Mybatis - -在前面JDBC的学习中,虽然我们能够通过JDBC来连接和操作数据库,但是哪怕只是完成一个SQL语句的执行,都需要编写大量的代码,更不用说如果我还需要进行实体类映射,将数据转换为我们可以直接操作的实体类型,JDBC很方便,但是还不够方便,我们需要一种更加简洁高效的方式来和数据库进行交互。 - -**再次强调:**学习厉害的框架或是厉害的技术,并不是为了一定要去使用它,而是它们能够使得我们在不同的开发场景下,合理地使用这些技术,以灵活地应对需要解决的问题。 - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fupload-images.jianshu.io%2Fupload_images%2F26720164-60462fc7927f8784.jpg&refer=http%3A%2F%2Fupload-images.jianshu.io&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1638550660&t=a0923b35afbaed1a168b74eb45ad2b4f) - -MyBatis 是一款优秀的持久层框架,它支持定制化 SQL、存储过程以及高级映射。MyBatis 避免了几乎所有的 JDBC 代码和手动设置参数以及获取结果集。MyBatis 可以使用简单的 XML 或注解来配置和映射原生信息,将接口和 Java 的 POJOs(Plain Ordinary Java Object,普通的 Java对象)映射成数据库中的记录。 - -我们依然使用传统的jar依赖方式,从最原始开始讲起,不使用Maven,有关Maven内容我们会在后面统一讲解!全程围绕官方文档讲解! - -这一块内容很多很杂,再次强调要多实践! - -### XML语言概述 - -在开始介绍Mybatis之前,XML语言发明最初是用于数据的存储和传输,它可以长这样: - -```xml - - - 阿伟 - 怎么又在玩电动啊 - - 10 - - - -``` - -如果你学习过前端知识,你会发现它和HTML几乎长得一模一样!但是请注意,虽然它们长得差不多,但是他们的意义却不同,HTML主要用于通过编排来展示数据,而XML主要是存放数据,它更像是一个配置文件!当然,浏览器也是可以直接打开XML文件的。 - -一个XML文件存在以下的格式规范: - -* 必须存在一个根节点,将所有的子标签全部包含。 -* 可以但不必须包含一个头部声明(主要是可以设定编码格式) -* 所有的标签必须成对出现,可以嵌套但不能交叉嵌套 -* 区分大小写。 -* 标签中可以存在属性,比如上面的`type="1"`就是`inner`标签的一个属性,属性的值由单引号或双引号包括。 - -XML文件也可以使用注释: - -```xml - - -``` - -通过IDEA我们可以使用`Ctrl`+`/`来快速添加注释文本(不仅仅适用于XML,还支持很多种类型的文件) - -那如果我们的内容中出现了`<`或是`>`字符,那该怎么办呢?我们就可以使用XML的转义字符来代替: - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg.jxdoc.com%2Fpic%2F28d1ff67caaedd3383c4d358%2F1-332-jpg_6_0_______-505-0-0-505.jpg&refer=http%3A%2F%2Fimg.jxdoc.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1639322216&t=88d1ea1adb9cbf1611eaf4c9fa16b8b0) - -如果嫌一个一个改太麻烦,也可以使用CD来快速创建不解析区域: - -```xml - - <><>是一点都不懂哦>>>]]> - -``` - -那么,我们现在了解了XML文件的定义,现在该如何去解析一个XML文件呢?比如我们希望将定义好的XML文件读取到Java程序中,这时该怎么做呢? - -JDK为我们内置了一个叫做`org.w3c`的XML解析库,我们来看看如何使用它来进行XML文件内容解析: - -```java -// 创建DocumentBuilderFactory对象 -DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); -// 创建DocumentBuilder对象 -try { - DocumentBuilder builder = factory.newDocumentBuilder(); - Document d = builder.parse("file:mappers/test.xml"); - // 每一个标签都作为一个节点 - NodeList nodeList = d.getElementsByTagName("test"); // 可能有很多个名字为test的标签 - Node rootNode = nodeList.item(0); // 获取首个 - - NodeList childNodes = rootNode.getChildNodes(); // 一个节点下可能会有很多个节点,比如根节点下就囊括了所有的节点 - //节点可以是一个带有内容的标签(它内部就还有子节点),也可以是一段文本内容 - - for (int i = 0; i < childNodes.getLength(); i++) { - Node child = childNodes.item(i); - if(child.getNodeType() == Node.ELEMENT_NODE) //过滤换行符之类的内容,因为它们都被认为是一个文本节点 - System.out.println(child.getNodeName() + ":" +child.getFirstChild().getNodeValue()); - // 输出节点名称,也就是标签名称,以及标签内部的文本(内部的内容都是子节点,所以要获取内部的节点) - } -} catch (Exception e) { - e.printStackTrace(); -} -``` - -当然,学习和使用XML只是为了更好地去认识Mybatis的工作原理,以及如何使用XML来作为Mybatis的配置文件,这是在开始之前必须要掌握的内容(使用Java读取XML内容不要求掌握,但是需要知道Mybatis就是通过这种方式来读取配置文件的) - -不仅仅是Mybatis,包括后面的Spring等众多框架都会用到XML来作为框架的配置文件! - -### 初次使用Mybatis - -那么我们首先来感受一下Mybatis给我们带来的便捷,就从搭建环境开始,中文文档网站:https://mybatis.org/mybatis-3/zh/configuration.html - -我们需要导入Mybatis的依赖,Jar包需要在github上下载,如果卡得一匹,连不上可以在视频简介处从分享的文件中获取。同样地放入到项目的根目录下,右键作为依赖即可!(依赖变多之后,我们可以将其放到一个单独的文件夹,不然会很繁杂) - -依赖导入完成后,我们就可以编写Mybatis的配置文件了(现在不是在Java代码中配置了,而是通过一个XML文件去配置,这样就使得硬编码的部分大大减少,项目后期打包成Jar运行不方便修复,但是通过配置文件,我们随时都可以去修改,就变得很方便了,同时代码量也大幅度减少,配置文件填写完成后,我们只需要关心项目的业务逻辑而不是如何去读取配置文件)我们按照官方文档给定的提示,在项目根目录下新建名为`mybatis-config.xml`的文件,并填写以下内容: - -```xml - - - - - - - - - - - - - - - -``` - -我们发现,在最上方还引入了一个叫做DTD(文档类型定义)的东西,它提前帮助我们规定了一些标签,我们就需要使用Mybatis提前帮助我们规定好的标签来进行配置(因为只有这样Mybatis才能正确识别我们配置的内容) - -通过进行配置,我们就告诉了Mybatis我们链接数据库的一些信息,包括URL、用户名、密码等,这样Mybatis就知道该链接哪个数据库、使用哪个账号进行登陆了(也可以不使用配置文件,这里不做讲解,还请各位小伙伴自行阅读官方文档) - -配置文件完成后,我们需要在Java程序启动时,让Mybatis对配置文件进行读取并得到一个`SqlSessionFactory`对象: - -```java -public static void main(String[] args) throws FileNotFoundException { - SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(new FileInputStream("mybatis-config.xml")); - try (SqlSession sqlSession = sqlSessionFactory.openSession(true)){ - //暂时还没有业务 - } -} -``` - -直接运行即可,虽然没有干什么事情,但是不会出现错误,如果之前的配置文件编写错误,直接运行会产生报错!那么现在我们来看看,`SqlSessionFactory`对象是什么东西: - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fwww.h5w3.com%2Fwp-content%2Fuploads%2F2021%2F01%2F1460000039107464.png&refer=http%3A%2F%2Fwww.h5w3.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1639372889&t=f37deb63f29f0dc2f8b6a3517a68b86c) - -每个基于 MyBatis 的应用都是以一个 SqlSessionFactory 的实例为核心的,我们可以通过`SqlSessionFactory`来创建多个新的会话,`SqlSession`对象,每个会话就相当于我不同的地方登陆一个账号去访问数据库,你也可以认为这就是之前JDBC中的`Statement`对象,会话之间相互隔离,没有任何关联。 - -而通过`SqlSession`就可以完成几乎所有的数据库操作,我们发现这个接口中定义了大量数据库操作的方法,因此,现在我们只需要通过一个对象就能完成数据库交互了,极大简化了之前的流程。 - -我们来尝试一下直接读取实体类,读取实体类肯定需要一个映射规则,比如类中的哪个字段对应数据库中的哪个字段,在查询语句返回结果后,Mybatis就会自动将对应的结果填入到对象的对应字段上。首先编写实体类,,直接使用Lombok是不是就很方便了: - -```java -import lombok.Data; - -@Data -public class Student { - int sid; //名称最好和数据库字段名称保持一致,不然可能会映射失败导致查询结果丢失 - String name; - String sex; -} -``` - -在根目录下重新创建一个mapper文件夹,新建名为`TestMapper.xml`的文件作为我们的映射器,并填写以下内容: - -```xml - - - - - -``` - -其中namespace就是命名空间,每个Mapper都是唯一的,因此需要用一个命名空间来区分,它还可以用来绑定一个接口。我们在里面写入了一个select标签,表示添加一个select操作,同时id作为操作的名称,resultType指定为我们刚刚定义的实体类,表示将数据库结果映射为`Student`类,然后就在标签中写入我们的查询语句即可。 - -编写好后,我们在配置文件中添加这个Mapper映射器: - -```xml - - - - -``` - -最后在程序中使用我们定义好的Mapper即可: - -```java -public static void main(String[] args) throws FileNotFoundException { - SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(new FileInputStream("mybatis-config.xml")); - try (SqlSession sqlSession = sqlSessionFactory.openSession(true)){ - List student = sqlSession.selectList("selectStudent"); - student.forEach(System.out::println); - } -} -``` - -我们会发现,Mybatis非常智能,我们只需要告诉一个映射关系,就能够直接将查询结果转化为一个实体类! - -### 配置Mybatis - -在了解了Mybatis为我们带来的便捷之后,现在我们就可以正式地去学习使用Mybatis了! - -由于`SqlSessionFactory`一般只需要创建一次,因此我们可以创建一个工具类来集中创建`SqlSession`,这样会更加方便一些: - -```java -public class MybatisUtil { - - //在类加载时就进行创建 - private static SqlSessionFactory sqlSessionFactory; - static { - try { - sqlSessionFactory = new SqlSessionFactoryBuilder().build(new FileInputStream("mybatis-config.xml")); - } catch (FileNotFoundException e) { - e.printStackTrace(); - } - } - - /** - * 获取一个新的会话 - * @param autoCommit 是否开启自动提交(跟JDBC是一样的,如果不自动提交,则会变成事务操作) - * @return SqlSession对象 - */ - public static SqlSession getSession(boolean autoCommit){ - return sqlSessionFactory.openSession(autoCommit); - } -} -``` - -现在我们只需要在main方法中这样写即可查询结果了: - -```java -public static void main(String[] args) { - try (SqlSession sqlSession = MybatisUtil.getSession(true)){ - List student = sqlSession.selectList("selectStudent"); - student.forEach(System.out::println); - } -} -``` - -之前我们演示了,如何创建一个映射器来将结果快速转换为实体类,但是这样可能还是不够方便,我们每次都需要去找映射器对应操作的名称,而且还要知道对应的返回类型,再通过`SqlSession`来执行对应的方法,能不能再方便一点呢? - -现在,我们可以通过`namespace`来绑定到一个接口上,利用接口的特性,我们可以直接指明方法的行为,而实际实现则是由Mybatis来完成。 - -```java -public interface TestMapper { - List selectStudent(); -} -``` - -将Mapper文件的命名空间修改为我们的接口,建议同时将其放到同名包中,作为内部资源: - -```xml - - - -``` - -作为内部资源后,我们需要修改一下配置文件中的mapper定义,不使用url而是resource表示是Jar内部的文件: - -```xml - - - -``` - -现在我们就可以直接通过`SqlSession`获取对应的实现类,通过接口中定义的行为来直接获取结果: - -```java -public static void main(String[] args) { - try (SqlSession sqlSession = MybatisUtil.getSession(true)){ - TestMapper testMapper = sqlSession.getMapper(TestMapper.class); - List student = testMapper.selectStudent(); - student.forEach(System.out::println); - } -} -``` - -那么肯定有人好奇,TestMapper明明是一个我们自己定义接口啊,Mybatis也不可能提前帮我们写了实现类啊,那这接口怎么就出现了一个实现类呢?我们可以通过调用`getClass()`方法来看看实现类是个什么: - -```java -TestMapper testMapper = sqlSession.getMapper(TestMapper.class); -System.out.println(testMapper.getClass()); -``` - -我们发现,实现类名称很奇怪,名称为`com.sun.proxy.$Proxy4`,它是通过动态代理生成的,相当于动态生成了一个实现类,而不是预先定义好的,有关Mybatis这一部分的原理,我们放在最后一节进行讲解。 - -接下来,我们再来看配置文件,之前我们并没有对配置文件进行一个详细的介绍: - -```java - - - - - - - - - - - - - - - - -``` - -首先就从`environments`标签说起,一般情况下,我们在开发中,都需要指定一个数据库的配置信息,包含连接URL、用户、密码等信息,而`environment`就是用于进行这些配置的!实际情况下可能会不止有一个数据库连接信息,比如开发过程中我们一般会使用本地的数据库,而如果需要将项目上传到服务器或是防止其他人的电脑上运行时,我们可能就需要配置另一个数据库的信息,因此,我们可以提前定义好所有的数据库信息,该什么时候用什么即可! - -在`environments`标签上有一个default属性,来指定默认的环境,当然如果我们希望使用其他环境,可以修改这个默认环境,也可以在创建工厂时选择环境: - -```java -sqlSessionFactory = new SqlSessionFactoryBuilder() - .build(new FileInputStream("mybatis-config.xml"), "环境ID"); -``` - -我们还可以给类型起一个别名,以简化Mapper的编写: - -```java - - - - -``` - -现在Mapper就可以直接使用别名了: - -```xml - - - -``` - -如果这样还是很麻烦,我们也可以直接让Mybatis去扫描一个包,并将包下的所有类自动起别名(别名为首字母小写的类名) - -```java - - - -``` - -也可以为指定实体类添加一个注解,来指定别名: - -```java -@Data -@Alias("lbwnb") -public class Student { - private int sid; - private String name; - private String sex; -} -``` - -当然,Mybatis也包含许多的基础配置,通过使用: - -```xml - - - -``` - -所有的配置项可以在中文文档处查询,本文不会进行详细介绍,在后面我们会提出一些比较重要的配置项。 - -有关配置文件的介绍就暂时到这里为止,我们讨论的重心应该是Mybatis的应用,而不是配置文件,所以省略了一部分内容的讲解。 - -### 增删改查 - -在了解了Mybatis的一些基本配置之后,我们就可以正式来使用Mybatis来进行数据库操作了! - -在前面我们演示了如何快速进行查询,我们只需要编写一个对应的映射器既可以了: - -```xml - - - -``` - -当然,如果你不喜欢使用实体类,那么这些属性还可以被映射到一个Map上: - -```xml - -``` - -```java -public interface TestMapper { - List selectStudent(); -} -``` - -Map中就会以键值对的形式来存放这些结果了。 - -通过设定一个`resultType`属性,让Mybatis知道查询结果需要映射为哪个实体类,要求字段名称保持一致。那么如果我们不希望按照这样的规则来映射呢?我们可以自定义`resultMap`来设定映射规则: - -```xml - - - - - -``` - -通过指定映射规则,我们现在名称和性别一栏就发生了交换,因为我们将其映射字段进行了交换。 - -如果一个类中存在多个构造方法,那么很有可能会出现这样的错误: - -```java -### Exception in thread "main" org.apache.ibatis.exceptions.PersistenceException: -### Error querying database. Cause: org.apache.ibatis.executor.ExecutorException: No constructor found in com.test.entity.Student matching [java.lang.Integer, java.lang.String, java.lang.String] -### The error may exist in com/test/mapper/TestMapper.xml -### The error may involve com.test.mapper.TestMapper.getStudentBySid -### The error occurred while handling results -### SQL: select * from student where sid = ? -### Cause: org.apache.ibatis.executor.ExecutorException: No constructor found in com.test.entity.Student matching [java.lang.Integer, java.lang.String, java.lang.String] - at org.apache.ibatis.exceptions.ExceptionFactory.wrapException(ExceptionFactory.java:30) - ... -``` - -这时就需要使用`constructor`标签来指定构造方法: - -```xml - - - - - - -``` - -值得注意的是,指定构造方法后,若此字段被填入了构造方法作为参数,将不会通过反射给字段单独赋值,而构造方法中没有传入的字段,依然会被反射赋值,有关`resultMap`的内容,后面还会继续讲解。 - -如果数据库中存在一个带下划线的字段,我们可以通过设置让其映射为以驼峰命名的字段,比如`my_test`映射为`myTest` - -```xml - - - -``` - -如果不设置,默认为不开启,也就是默认需要名称保持一致。 - -我们接着来看看条件查询,既然是条件查询,那么肯定需要我们传入查询条件,比如现在我们想通过sid字段来通过学号查找信息: - -```java -Student getStudentBySid(int sid); -``` - -```xml - -``` - -我们通过使用`#{xxx}`或是`${xxx}`来填入我们给定的属性,实际上Mybatis本质也是通过`PreparedStatement`首先进行一次预编译,有效地防止SQL注入问题,但是如果使用`${xxx}`就不再是通过预编译,而是直接传值,因此我们一般都使用`#{xxx}`来进行操作。 - -使用`parameterType`属性来指定参数类型(非必须,可以不用,推荐不用) - -接着我们来看插入、更新和删除操作,其实与查询操作差不多,不过需要使用对应的标签,比如插入操作: - -```xml - - insert into student(name, sex) values(#{name}, #{sex}) - -``` - -```java -int addStudent(Student student); -``` - -我们这里使用的是一个实体类,我们可以直接使用实体类里面对应属性替换到SQL语句中,只需要填写属性名称即可,和条件查询是一样的。 - -### 复杂查询 - -一个老师可以教授多个学生,那么能否一次性将老师的学生全部映射给此老师的对象呢,比如: - -```java -@Data -public class Teacher { - int tid; - String name; - List studentList; -} -``` - -映射为Teacher对象时,同时将其教授的所有学生一并映射为List列表,显然这是一种一对多的查询,那么这时就需要进行复杂查询了。而我们之前编写的都非常简单,直接就能完成映射,因此我们现在需要使用`resultMap`来自定义映射规则: - -```xml - - - - - - - - - - - -``` - -可以看到,我们的查询结果是一个多表联查的结果,而联查的数据就是我们需要映射的数据(比如这里是一个老师有N个学生,联查的结果也是这一个老师对应N个学生的N条记录),其中`id`标签用于在多条记录中辨别是否为同一个对象的数据,比如上面的查询语句得到的结果中,`tid`这一行始终为`1`,因此所有的记录都应该是`tid=1`的教师的数据,而不应该变为多个教师的数据,如果不加id进行约束,那么会被识别成多个教师的数据! - -通过使用collection来表示将得到的所有结果合并为一个集合,比如上面的数据中每个学生都有单独的一条记录,因此tid相同的全部学生的记录就可以最后合并为一个List,得到最终的映射结果,当然,为了区分,最好也设置一个id,只不过这个例子中可以当做普通的`result`使用。 - -了解了一对多,那么多对一又该如何查询呢,比如每个学生都有一个对应的老师,现在Student新增了一个Teacher对象,那么现在又该如何去处理呢? - -```java -@Data -@Accessors(chain = true) -public class Student { - private int sid; - private String name; - private String sex; - private Teacher teacher; -} - -@Data -public class Teacher { - int tid; - String name; -} -``` - -现在我们希望的是,每次查询到一个Student对象时都带上它的老师,同样的,我们也可以使用`resultMap`来实现(先修改一下老师的类定义,不然会很麻烦): - -```xml - - - - - - - - - - -``` - -通过使用`association`进行关联,形成多对一的关系,实际上和一对多是同理的,都是对查询结果的一种处理方式罢了。 - -### 事务操作 - -我们可以在获取`SqlSession`关闭自动提交来开启事务模式,和JDBC其实都差不多: - -```java -public static void main(String[] args) { - try (SqlSession sqlSession = MybatisUtil.getSession(false)){ - TestMapper testMapper = sqlSession.getMapper(TestMapper.class); - - testMapper.addStudent(new Student().setSex("男").setName("小王")); - - testMapper.selectStudent().forEach(System.out::println); - } -} -``` - -我们发现,在关闭自动提交后,我们的内容是没有进入到数据库的,现在我们来试一下在最后提交事务: - -```java -sqlSession.commit(); -``` - -在事务提交后,我们的内容才会被写入到数据库中。现在我们来试试看回滚操作: - -```java -try (SqlSession sqlSession = MybatisUtil.getSession(false)){ - TestMapper testMapper = sqlSession.getMapper(TestMapper.class); - - testMapper.addStudent(new Student().setSex("男").setName("小王")); - - testMapper.selectStudent().forEach(System.out::println); - sqlSession.rollback(); - sqlSession.commit(); -} -``` - -回滚操作也印证成功。 - -### 动态SQL - -动态 SQL 是 MyBatis 的强大特性之一。如果你使用过 JDBC 或其它类似的框架,你应该能理解根据不同条件拼接 SQL 语句有多痛苦,例如拼接时要确保不能忘记添加必要的空格,还要注意去掉列表最后一个列名的逗号。利用动态 SQL,可以彻底摆脱这种痛苦。 - -我们直接使用官网的例子进行讲解。 - -### 缓存机制 - -MyBatis 内置了一个强大的事务性查询缓存机制,它可以非常方便地配置和定制。 - -其实缓存机制我们在之前学习IO流的时候已经提及过了,我们可以提前将一部分内容放入缓存,下次需要获取数据时,我们就可以直接从缓存中读取,这样的话相当于直接从内存中获取而不是再去向数据库索要数据,效率会更高。 - -因此Mybatis内置了一个缓存机制,我们查询时,如果缓存中存在数据,那么我们就可以直接从缓存中获取,而不是再去向数据库进行请求。 - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fresource.shangmayuan.com%2Fdroxy-blog%2F2021%2F03%2F02%2F071d25e4f9d841e0ac9df54038d98fd0-2.png&refer=http%3A%2F%2Fresource.shangmayuan.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1639463836&t=38cf5a85386f76cfd22ca3c6dcc5b6bb) - -Mybatis存在一级缓存和二级缓存,我们首先来看一下一级缓存,默认情况下,只启用了本地的会话缓存,它仅仅对一个会话中的数据进行缓存(一级缓存无法关闭,只能调整),我们来看看下面这段代码: - -```java -public static void main(String[] args) throws InterruptedException { - try (SqlSession sqlSession = MybatisUtil.getSession(true)){ - TestMapper testMapper = sqlSession.getMapper(TestMapper.class); - Student student1 = testMapper.getStudentBySid(1); - Student student2 = testMapper.getStudentBySid(1); - System.out.println(student1 == student2); - } -} -``` - -我们发现,两次得到的是同一个Student对象,也就是说我们第二次查询并没有重新去构造对象,而是直接得到之前创建好的对象。如果还不是很明显,我们可以修改一下实体类: - -```java -@Data -@Accessors(chain = true) -public class Student { - - public Student(){ - System.out.println("我被构造了"); - } - - private int sid; - private String name; - private String sex; -} -``` - -我们通过前面的学习得知Mybatis在映射为对象时,在只有一个构造方法的情况下,无论你构造方法写成什么样子,都会去调用一次构造方法,如果存在多个构造方法,那么就会去找匹配的构造方法。我们可以通过查看构造方法来验证对象被创建了几次。 - -结果显而易见,只创建了一次,也就是说当第二次进行同样的查询时,会直接使用第一次的结果,因为第一次的结果已经被缓存了。 - -那么如果我修改了数据库中的内容,缓存还会生效吗: - -```java -public static void main(String[] args) throws InterruptedException { - try (SqlSession sqlSession = MybatisUtil.getSession(true)){ - TestMapper testMapper = sqlSession.getMapper(TestMapper.class); - Student student1 = testMapper.getStudentBySid(1); - testMapper.addStudent(new Student().setName("小李").setSex("男")); - Student student2 = testMapper.getStudentBySid(1); - System.out.println(student1 == student2); - } -} -``` - -我们发现,当我们进行了插入操作后,缓存就没有生效了,我们再次进行查询得到的是一个新创建的对象。 - -也就是说,一级缓存,在进行DML操作后,会使得缓存失效,也就是说Mybatis知道我们对数据库里面的数据进行了修改,所以之前缓存的内容可能就不是当前数据库里面最新的内容了。还有一种情况就是,当前会话结束后,也会清理全部的缓存,因为已经不会再用到了。但是一定注意,一级缓存只针对于单个会话,多个会话之间不相通。 - -```java -public static void main(String[] args) { - try (SqlSession sqlSession = MybatisUtil.getSession(true)){ - TestMapper testMapper = sqlSession.getMapper(TestMapper.class); - - Student student2; - try(SqlSession sqlSession2 = MybatisUtil.getSession(true)){ - TestMapper testMapper2 = sqlSession2.getMapper(TestMapper.class); - student2 = testMapper2.getStudentBySid(1); - } - - Student student1 = testMapper.getStudentBySid(1); - System.out.println(student1 == student2); - } -} -``` - -**注意:**一个会话DML操作只会重置当前会话的缓存,不会重置其他会话的缓存,也就是说,其他会话缓存是不会更新的! - -一级缓存给我们提供了很高速的访问效率,但是它的作用范围实在是有限,如果一个会话结束,那么之前的缓存就全部失效了,但是我们希望缓存能够扩展到所有会话都能使用,因此我们可以通过二级缓存来实现,二级缓存默认是关闭状态,要开启二级缓存,我们需要在映射器XML文件中添加: - -```xml - -``` - -可见二级缓存是Mapper级别的,也就是说,当一个会话失效时,它的缓存依然会存在于二级缓存中,因此如果我们再次创建一个新的会话会直接使用之前的缓存,我们首先根据官方文档进行一些配置: - -```xml - -``` - -我们来编写一个代码: - -```java -public static void main(String[] args) { - Student student; - try (SqlSession sqlSession = MybatisUtil.getSession(true)){ - TestMapper testMapper = sqlSession.getMapper(TestMapper.class); - student = testMapper.getStudentBySid(1); - } - - try (SqlSession sqlSession2 = MybatisUtil.getSession(true)){ - TestMapper testMapper2 = sqlSession2.getMapper(TestMapper.class); - Student student2 = testMapper2.getStudentBySid(1); - System.out.println(student2 == student); - } -} -``` - -我们可以看到,上面的代码中首先是第一个会话在进行读操作,完成后会结束会话,而第二个操作重新创建了一个新的会话,再次执行了同样的查询,我们发现得到的依然是缓存的结果。 - -那么如果我不希望某个方法开启缓存呢?我们可以添加useCache属性来关闭缓存: - -```xml - -``` - -我们也可以使用flushCache="false"在每次执行后都清空缓存,通过这这个我们还可以控制DML操作完成之后不清空缓存。 - -```xml - -``` - -添加了二级缓存之后,会先从二级缓存中查找数据,当二级缓存中没有时,才会从一级缓存中获取,当一级缓存中都还没有数据时,才会请求数据库,因此我们再来执行上面的代码: - -```java -public static void main(String[] args) { - try (SqlSession sqlSession = MybatisUtil.getSession(true)){ - TestMapper testMapper = sqlSession.getMapper(TestMapper.class); - - Student student2; - try(SqlSession sqlSession2 = MybatisUtil.getSession(true)){ - TestMapper testMapper2 = sqlSession2.getMapper(TestMapper.class); - student2 = testMapper2.getStudentBySid(1); - } - - Student student1 = testMapper.getStudentBySid(1); - System.out.println(student1 == student2); - } -} -``` - -得到的结果就会是同一个对象了,因为现在是优先从二级缓存中获取。 - -读取顺序:二级缓存 => 一级缓存 => 数据库 - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fupload-images.jianshu.io%2Fupload_images%2F2176079-2e6599c454e7af19.png&refer=http%3A%2F%2Fupload-images.jianshu.io&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1639471352&t=c7c1d6b11de1ad9af91e092590c58d83) - -虽然缓存机制给我们提供了很大的性能提升,但是缓存存在一个问题,我们之前在`计算机组成原理`中可能学习过缓存一致性问题,也就是说当多个CPU在操作自己的缓存时,可能会出现各自的缓存内容不同步的问题,而Mybatis也会这样,我们来看看这个例子: - -```java -public static void main(String[] args) throws InterruptedException { - try (SqlSession sqlSession = MybatisUtil.getSession(true)){ - TestMapper testMapper = sqlSession.getMapper(TestMapper.class); - while (true){ - Thread.sleep(3000); - System.out.println(testMapper.getStudentBySid(1)); - } - } -} -``` - -我们现在循环地每三秒读取一次,而在这个过程中,我们使用IDEA手动修改数据库中的数据,将1号同学的学号改成100,那么理想情况下,下一次读取将无法获取到小明,因为小明的学号已经发生变化了。 - -但是结果却是依然能够读取,并且sid并没有发生改变,这也证明了Mybatis的缓存在生效,因为我们是从外部进行修改,Mybatis不知道我们修改了数据,所以依然在使用缓存中的数据,但是这样很明显是不正确的,因此,如果存在多台服务器或者是多个程序都在使用Mybatis操作同一个数据库,并且都开启了缓存,需要解决这个问题,要么就得关闭Mybatis的缓存来保证一致性: - -```xml - - - -``` - -```xml - -``` - -要么就需要实现缓存共用,也就是让所有的Mybatis都使用同一个缓存进行数据存取,在后面,我们会继续学习Redis、Ehcache、Memcache等缓存框架,通过使用这些工具,就能够很好地解决缓存一致性问题。 - -### 使用注解开发 - -在之前的开发中,我们已经体验到Mybatis为我们带来的便捷了,我们只需要编写对应的映射器,并将其绑定到一个接口上,即可直接通过该接口执行我们的SQL语句,极大的简化了我们之前JDBC那样的代码编写模式。那么,能否实现无需xml映射器配置,而是直接使用注解在接口上进行配置呢?答案是可以的,也是现在推荐的一种方式(也不是说XML就不要去用了,由于Java 注解的表达能力和灵活性十分有限,可能相对于XML配置某些功能实现起来会不太好办,但是在大部分场景下,直接使用注解开发已经绰绰有余了) - -首先我们来看一下,使用XML进行映射器编写时,我们需要现在XML中定义映射规则和SQL语句,然后再将其绑定到一个接口的方法定义上,然后再使用接口来执行: - -```xml - - insert into student(name, sex) values(#{name}, #{sex}) - -``` - -```java -int addStudent(Student student); -``` - -而现在,我们可以直接使用注解来实现,每个操作都有一个对应的注解: - -```java -@Insert("insert into student(name, sex) values(#{name}, #{sex})") -int addStudent(Student student); -``` - -当然,我们还需要修改一下配置文件中的映射器注册: - -```java - - - - -``` - -通过直接指定Class,来让Mybatis知道我们这里有一个通过注解实现的映射器。 - -我们接着来看一下,如何使用注解进行自定义映射规则: - -```java -@Results({ - @Result(id = true, column = "sid", property = "sid"), - @Result(column = "sex", property = "name"), - @Result(column = "name", property = "sex") -}) -@Select("select * from student") -List getAllStudent(); -``` - -直接通过`@Results`注解,就可以直接进行配置了,此注解的value是一个`@Result`注解数组,每个`@Result`注解都都一个单独的字段配置,其实就是我们之前在XML映射器中写的: - -```xml - - - - - -``` - -现在我们就可以通过注解来自定义映射规则了。那么如何使用注解来完成复杂查询呢?我们还是使用一个老师多个学生的例子: - -```java -@Results({ - @Result(id = true, column = "tid", property = "tid"), - @Result(column = "name", property = "name"), - @Result(column = "tid", property = "studentList", many = - @Many(select = "getStudentByTid") - ) -}) -@Select("select * from teacher where tid = #{tid}") -Teacher getTeacherBySid(int tid); - -@Select("select * from student inner join teach on student.sid = teach.sid where tid = #{tid}") -List getStudentByTid(int tid); -``` - -我们发现,多出了一个子查询,而这个子查询是单独查询该老师所属学生的信息,而子查询结果作为`@Result`注解的一个many结果,代表子查询的所有结果都归入此集合中(也就是之前的collection标签) - -```xml - - - - - - - - - -``` - -同理,`@Result`也提供了`@One`子注解来实现一对一的关系表示,类似于之前的`assocation`标签: - -```java -@Results({ - @Result(id = true, column = "sid", property = "sid"), - @Result(column = "sex", property = "name"), - @Result(column = "name", property = "sex"), - @Result(column = "sid", property = "teacher", one = - @One(select = "getTeacherBySid") - ) -}) -@Select("select * from student") -List getAllStudent(); -``` - -如果现在我希望直接使用注解编写SQL语句但是我希望映射规则依然使用XML来实现,这时该怎么办呢? - -```java -@ResultMap("test") -@Select("select * from student") -List getAllStudent(); -``` - -提供了`@ResultMap`注解,直接指定ID即可,这样我们就可以使用XML中编写的映射规则了,这里就不再演示了。 - -那么如果出现之前的两个构造方法的情况,且没有任何一个构造方法匹配的话,该怎么处理呢? - -```java -@Data -@Accessors(chain = true) -public class Student { - - public Student(int sid){ - System.out.println("我是一号构造方法"+sid); - } - - public Student(int sid, String name){ - System.out.println("我是二号构造方法"+sid+name); - } - - private int sid; - private String name; - private String sex; -} -``` - -我们可以通过`@ConstructorArgs`注解来指定构造方法: - -```java -@ConstructorArgs({ - @Arg(column = "sid", javaType = int.class), - @Arg(column = "name", javaType = String.class) -}) -@Select("select * from student where sid = #{sid} and sex = #{sex}") -Student getStudentBySidAndSex(@Param("sid") int sid, @Param("sex") String sex); -``` - -得到的结果和使用`constructor`标签效果一致,这里就不多做讲解了。 - -我们发现,当参数列表中出现两个以上的参数时,会出现错误: - -```java -@Select("select * from student where sid = #{sid} and sex = #{sex}") -Student getStudentBySidAndSex(int sid, String sex); -``` - -```java -Exception in thread "main" org.apache.ibatis.exceptions.PersistenceException: -### Error querying database. Cause: org.apache.ibatis.binding.BindingException: Parameter 'sid' not found. Available parameters are [arg1, arg0, param1, param2] -### Cause: org.apache.ibatis.binding.BindingException: Parameter 'sid' not found. Available parameters are [arg1, arg0, param1, param2] - at org.apache.ibatis.exceptions.ExceptionFactory.wrapException(ExceptionFactory.java:30) - at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:153) - at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:145) - at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:140) - at org.apache.ibatis.session.defaults.DefaultSqlSession.selectOne(DefaultSqlSession.java:76) - at org.apache.ibatis.binding.MapperMethod.execute(MapperMethod.java:87) - at org.apache.ibatis.binding.MapperProxy$PlainMethodInvoker.invoke(MapperProxy.java:145) - at org.apache.ibatis.binding.MapperProxy.invoke(MapperProxy.java:86) - at com.sun.proxy.$Proxy6.getStudentBySidAndSex(Unknown Source) - at com.test.Main.main(Main.java:16) -``` - -原因是Mybatis不明确到底哪个参数是什么,因此我们可以添加`@Param`来指定参数名称: - -```java -@Select("select * from student where sid = #{sid} and sex = #{sex}") -Student getStudentBySidAndSex(@Param("sid") int sid, @Param("sex") String sex); -``` - -**探究:**要是我两个参数一个是基本类型一个是对象类型呢? - -```java -System.out.println(testMapper.addStudent(100, new Student().setName("小陆").setSex("男"))); -``` - -```java -@Insert("insert into student(sid, name, sex) values(#{sid}, #{name}, #{sex})") -int addStudent(@Param("sid") int sid, @Param("student") Student student); -``` - -那么这个时候,就出现问题了,Mybatis就不能明确这些属性是从哪里来的: - -```java -### SQL: insert into student(sid, name, sex) values(?, ?, ?) -### Cause: org.apache.ibatis.binding.BindingException: Parameter 'name' not found. Available parameters are [student, param1, sid, param2] - at org.apache.ibatis.exceptions.ExceptionFactory.wrapException(ExceptionFactory.java:30) - at org.apache.ibatis.session.defaults.DefaultSqlSession.update(DefaultSqlSession.java:196) - at org.apache.ibatis.session.defaults.DefaultSqlSession.insert(DefaultSqlSession.java:181) - at org.apache.ibatis.binding.MapperMethod.execute(MapperMethod.java:62) - at org.apache.ibatis.binding.MapperProxy$PlainMethodInvoker.invoke(MapperProxy.java:145) - at org.apache.ibatis.binding.MapperProxy.invoke(MapperProxy.java:86) - at com.sun.proxy.$Proxy6.addStudent(Unknown Source) - at com.test.Main.main(Main.java:16) -``` - -那么我们就通过参数名称.属性的方式去让Mybatis知道我们要用的是哪个属性: - -```java -@Insert("insert into student(sid, name, sex) values(#{sid}, #{student.name}, #{student.sex})") -int addStudent(@Param("sid") int sid, @Param("student") Student student); -``` - -那么如何通过注解控制缓存机制呢? - -```java -@CacheNamespace(readWrite = false) -public interface MyMapper { - - @Select("select * from student") - @Options(useCache = false) - List getAllStudent(); -``` - -使用`@CacheNamespace`注解直接定义在接口上即可,然后我们可以通过使用`@Options`来控制单个操作的缓存启用。 - -### 探究Mybatis的动态代理机制 - -在探究动态代理机制之前,我们要先聊聊什么是代理:其实顾名思义,就好比我开了个大棚,里面栽种的西瓜,那么西瓜成熟了是不是得去卖掉赚钱,而我们的西瓜非常多,一个人肯定卖不过来,肯定就要去多找几个开水果摊的帮我们卖,这就是一种代理。实际上是由水果摊老板在帮我们卖瓜,我们只告诉老板卖多少钱,而至于怎么卖的是由水果摊老板决定的。 - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg-blog.csdnimg.cn%2F2020112311143434.png%3Fx-oss-process%26%2361%3Bimage%2Fwatermark%2Ctype_ZmFuZ3poZW5naGVpdGk%2Cshadow_10%2Ctext_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0hhdHR5MTkyMA%26%2361%3B%26%2361%3B%2Csize_16%2Ccolor_FFFFFF%2Ct_7&refer=http%3A%2F%2Fimg-blog.csdnimg.cn&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1639472394&t=b5590551c75049e91fc497b9920bdb83) - -那么现在我们来尝试实现一下这样的类结构,首先定义一个接口用于规范行为: - -```java -public interface Shopper { - - //卖瓜行为 - void saleWatermelon(String customer); -} -``` - -然后需要实现一下卖瓜行为,也就是我们要告诉老板卖多少钱,这里就直接写成成功出售: - -```java -public class ShopperImpl implements Shopper{ - - //卖瓜行为的实现 - @Override - public void saleWatermelon(String customer) { - System.out.println("成功出售西瓜给 ===> "+customer); - } -} -``` - -最后老板代理后肯定要用自己的方式去出售这些西瓜,成交之后再按照我们告诉老板的价格进行出售: - -```java -public class ShopperProxy implements Shopper{ - - private final Shopper impl; - - public ShopperProxy(Shopper impl){ - this.impl = impl; - } - - //代理卖瓜行为 - @Override - public void saleWatermelon(String customer) { - //首先进行 代理商讨价还价行为 - System.out.println(customer + ":哥们,这瓜多少钱一斤啊?"); - System.out.println("老板:两块钱一斤。"); - System.out.println(customer + ":你这瓜皮子是金子做的,还是瓜粒子是金子做的?"); - System.out.println("老板:你瞅瞅现在哪有瓜啊,这都是大棚的瓜,你嫌贵我还嫌贵呢。"); - System.out.println(customer + ":给我挑一个。"); - - impl.saleWatermelon(customer); //讨价还价成功,进行我们告诉代理商的卖瓜行为 - } -} -``` - -现在我们来试试看: - -```java -public class Main { - public static void main(String[] args) { - Shopper shopper = new ShopperProxy(new ShopperImpl()); - shopper.saleWatermelon("小强"); - } -} -``` - -这样的操作称为静态代理,也就是说我们需要提前知道接口的定义并进行实现才可以完成代理,而Mybatis这样的是无法预知代理接口的,我们就需要用到动态代理。 - -JDK提供的反射框架就为我们很好地解决了动态代理的问题,在这里相当于对JavaSE阶段反射的内容进行一个补充。 - -```java -public class ShopperProxy implements InvocationHandler { - - Object target; - public ShopperProxy(Object target){ - this.target = target; - } - - @Override - public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { - String customer = (String) args[0]; - System.out.println(customer + ":哥们,这瓜多少钱一斤啊?"); - System.out.println("老板:两块钱一斤。"); - System.out.println(customer + ":你这瓜皮子是金子做的,还是瓜粒子是金子做的?"); - System.out.println("老板:你瞅瞅现在哪有瓜啊,这都是大棚的瓜,你嫌贵我还嫌贵呢。"); - System.out.println(customer + ":行,给我挑一个。"); - return method.invoke(target, args); - } -} -``` - -通过实现InvocationHandler来成为一个动态代理,我们发现它提供了一个invoke方法,用于调用被代理对象的方法并完成我们的代理工作。现在就可以通过` Proxy.newProxyInstance`来生成一个动态代理类: - -```java -public static void main(String[] args) { - Shopper impl = new ShopperImpl(); - Shopper shopper = (Shopper) Proxy.newProxyInstance(impl.getClass().getClassLoader(), - impl.getClass().getInterfaces(), new ShopperProxy(impl)); - shopper.saleWatermelon("小强"); - System.out.println(shopper.getClass()); -} -``` - -通过打印类型我们发现,就是我们之前看到的那种奇怪的类:`class com.sun.proxy.$Proxy0`,因此Mybatis其实也是这样的来实现的(肯定有人问了:Mybatis是直接代理接口啊,你这个不还是要把接口实现了吗?)那我们来改改,现在我们不代理任何类了,直接做接口实现: - -```java -public class ShopperProxy implements InvocationHandler { - - @Override - public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { - String customer = (String) args[0]; - System.out.println(customer + ":哥们,这瓜多少钱一斤啊?"); - System.out.println("老板:两块钱一斤。"); - System.out.println(customer + ":你这瓜皮子是金子做的,还是瓜粒子是金子做的?"); - System.out.println("老板:你瞅瞅现在哪有瓜啊,这都是大棚的瓜,你嫌贵我还嫌贵呢。"); - System.out.println(customer + ":行,给我挑一个。"); - return null; - } -} -``` - -```java -public static void main(String[] args) { - Shopper shopper = (Shopper) Proxy.newProxyInstance(Shopper.class.getClassLoader(), - new Class[]{ Shopper.class }, //因为本身就是接口,所以直接用就行 - new ShopperProxy()); - shopper.saleWatermelon("小强"); - System.out.println(shopper.getClass()); -} -``` - -我们可以去看看Mybatis的源码。 - -Mybatis的学习差不多就到这里为止了,不过,同样类型的框架还有很多,Mybatis属于半自动框架,SQL语句依然需要我们自己编写,虽然存在一定的麻烦,但是会更加灵活,而后面我们还会学习JPA,它是全自动的框架,你几乎见不到SQL的影子! - -*** - -## 使用JUnit进行单元测试 - -首先一问:我们为什么需要单元测试? - -随着我们的项目逐渐变大,比如我们之前编写的图书管理系统,我们都是边在写边在测试,而我们当时使用的测试方法,就是直接在主方法中运行测试,但是,在很多情况下,我们的项目可能会很庞大,不可能每次都去完整地启动一个项目来测试某一个功能,这样显然会降低我们的开发效率,因此,我们需要使用单元测试来帮助我们针对于某个功能或是某个模块单独运行代码进行测试,而不是启动整个项目。 - -同时,在我们项目的维护过程中,难免会涉及到一些原有代码的修改,很有可能出现改了代码导致之前的功能出现问题(牵一发而动全身),而我们又不一定能立即察觉到,因此,我们可以提前保存一些测试用例,每次完成代码后都可以跑一遍测试用例,来确保之前的功能没有因为后续的修改而出现问题。 - -我们还可以利用单元测试来评估某个模块或是功能的耗时和性能,快速排查导致程序运行缓慢的问题,这些都可以通过单元测试来完成,可见单元测试对于开发的重要性。 - -### 尝试JUnit - -首先需要导入JUnit依赖,我们在这里使用Junit4进行介绍,最新的Junit5放到Maven板块一起讲解,Jar包已经放在视频下方简介中,直接去下载即可。同时IDEA需要安装JUnit插件(默认是已经捆绑安装的,因此无需多余配置) - -现在我们创建一个新的类,来编写我们的单元测试用例: - -```java -public class TestMain { - @Test - public void method(){ - System.out.println("我是测试用例1"); - } - - @Test - public void method2(){ - System.out.println("我是测试用例2"); - } -} -``` - -我们可以点击类前面的测试按钮,或是单个方法前的测试按钮,如果点击类前面的测试按钮,会执行所有的测试用例。 - -运行测试后,我们发现控制台得到了一个测试结果,显示为绿色表示测试通过。 - -只需要通过打上`@Test`注解,即可将一个方法标记为测试案例,我们可以直接运行此测试案例,但是我们编写的测试方法有以下要求: - -* 方法必须是public的 -* 不能是静态方法 -* 返回值必须是void -* 必须是没有任何参数的方法 - -对于一个测试案例来说,我们肯定希望测试的结果是我们所期望的一个值,因此,如果测试的结果并不是我们所期望的结果,那么这个测试就应该没有成功通过! - -我们可以通过断言工具类来进行判定: - -```java -public class TestMain { - @Test - public void method(){ - System.out.println("我是测试案例!"); - Assert.assertEquals(1, 2); //参数1是期盼值,参数2是实际测试结果值 - } -} -``` - -通过运行代码后,我们发现测试过程中抛出了一个错误,并且IDEA给我们显示了期盼结果和测试结果,那么现在我们来测试一个案例,比如我们想查看冒泡排序的编写是否正确: - -```java -@Test -public void method(){ - int[] arr = {0, 4, 5, 2, 6, 9, 3, 1, 7, 8}; - - //错误的冒泡排序 - for (int i = 0; i < arr.length - 1; i++) { - for (int j = 0; j < arr.length - 1 - i; j++) { - if(arr[j] > arr[j + 1]){ - int tmp = arr[j]; - arr[j] = arr[j+1]; - // arr[j+1] = tmp; - } - } - } - - Assert.assertArrayEquals(new int[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, arr); -} -``` - -通过测试,我们发现得到的结果并不是我们想要的结果,因此现在我们需要去修改为正确的冒泡排序,修改后,测试就能正确通过了。我们还可以再通过一个案例来更加深入地了解测试,现在我们想测试从数据库中取数据是否为我们预期的数据: - -```java -@Test -public void method(){ - try (SqlSession sqlSession = MybatisUtil.getSession(true)){ - TestMapper mapper = sqlSession.getMapper(TestMapper.class); - Student student = mapper.getStudentBySidAndSex(1, "男"); - - Assert.assertEquals(new Student().setName("小明").setSex("男").setSid(1), student); - } -} -``` - -那么如果我们在进行所有的测试之前需要做一些前置操作该怎么办呢,一种办法是在所有的测试用例前面都加上前置操作,但是这样显然是很冗余的,因为一旦发生修改就需要挨个进行修改,因此我们需要更加智能的方法,我们可以通过`@Before`注解来添加测试用例开始之前的前置操作: - -```java -public class TestMain { - - private SqlSessionFactory sqlSessionFactory; - @Before - public void before(){ - System.out.println("测试前置正在初始化..."); - try { - sqlSessionFactory = new SqlSessionFactoryBuilder() - .build(new FileInputStream("mybatis-config.xml")); - } catch (FileNotFoundException e) { - e.printStackTrace(); - } - System.out.println("测试初始化完成,正在开始测试案例..."); - } - - @Test - public void method1(){ - try (SqlSession sqlSession = sqlSessionFactory.openSession(true)){ - TestMapper mapper = sqlSession.getMapper(TestMapper.class); - Student student = mapper.getStudentBySidAndSex(1, "男"); - - Assert.assertEquals(new Student().setName("小明").setSex("男").setSid(1), student); - System.out.println("测试用例1通过!"); - } - } - - @Test - public void method2(){ - try (SqlSession sqlSession = sqlSessionFactory.openSession(true)){ - TestMapper mapper = sqlSession.getMapper(TestMapper.class); - Student student = mapper.getStudentBySidAndSex(2, "女"); - - Assert.assertEquals(new Student().setName("小红").setSex("女").setSid(2), student); - System.out.println("测试用例2通过!"); - } - } -} -``` - -同理,在所有的测试完成之后,我们还想添加一个收尾的动作,那么只需要使用`@After`注解即可添加结束动作: - -```java -@After -public void after(){ - System.out.println("测试结束,收尾工作正在进行..."); -} -``` - -有关JUnit的使用我们就暂时只介绍这么多。 - -*** - -## JUL日志系统 - -首先一问:我们为什么需要日志系统? - -我们之前一直都在使用`System.out.println`来打印信息,但是,如果项目中存在大量的控制台输出语句,会显得很凌乱,而且日志的粒度是不够细的,假如我们现在希望,项目只在debug的情况下打印某些日志,而在实际运行时不打印日志,采用直接输出的方式就很难实现了,因此我们需要使用日志框架来规范化日志输出。 - -而JDK为我们提供了一个自带的日志框架,位于`java.util.logging`包下,我们可以使用此框架来实现日志的规范化打印,使用起来非常简单: - -```java -public class Main { - public static void main(String[] args) { - // 首先获取日志打印器 - Logger logger = Logger.getLogger(Main.class.getName()); - // 调用info来输出一个普通的信息,直接填写字符串即可 - logger.info("我是普通的日志"); - } -} -``` - -我们可以在主类中使用日志打印,得到日志的打印结果: - -```tex -十一月 15, 2021 12:55:37 下午 com.test.Main main -信息: 我是普通的日志 -``` - -我们发现,通过日志输出的结果会更加规范。 - -### JUL日志讲解 - -日志分为7个级别,详细信息我们可以在Level类中查看: - -* SEVERE(最高值)- 一般用于代表严重错误 -* WARNING - 一般用于表示某些警告,但是不足以判断为错误 -* INFO (默认级别) - 常规消息 -* CONFIG -* FINE -* FINER -* FINEST(最低值) - -我们之前通过`info`方法直接输出的结果就是使用的默认级别的日志,我们可以通过`log`方法来设定该条日志的输出级别: - -```java -public static void main(String[] args) { - Logger logger = Logger.getLogger(Main.class.getName()); - logger.log(Level.SEVERE, "严重的错误", new IOException("我就是错误")); - logger.log(Level.WARNING, "警告的内容"); - logger.log(Level.INFO, "普通的信息"); - logger.log(Level.CONFIG, "级别低于普通信息"); -} -``` - -我们发现,级别低于默认级别的日志信息,无法输出到控制台,我们可以通过设置来修改日志的打印级别: - -```java -public static void main(String[] args) { - Logger logger = Logger.getLogger(Main.class.getName()); - - //修改日志级别 - logger.setLevel(Level.CONFIG); - //不使用父日志处理器 - logger.setUseParentHandlers(false); - //使用自定义日志处理器 - ConsoleHandler handler = new ConsoleHandler(); - handler.setLevel(Level.CONFIG); - logger.addHandler(handler); - - logger.log(Level.SEVERE, "严重的错误", new IOException("我就是错误")); - logger.log(Level.WARNING, "警告的内容"); - logger.log(Level.INFO, "普通的信息"); - logger.log(Level.CONFIG, "级别低于普通信息"); -} -``` - -每个`Logger`都有一个父日志打印器,我们可以通过`getParent()`来获取: - -```java -public static void main(String[] args) throws IOException { - Logger logger = Logger.getLogger(Main.class.getName()); - System.out.println(logger.getParent().getClass()); -} -``` - -我们发现,得到的是`java.util.logging.LogManager$RootLogger`这个类,它默认使用的是ConsoleHandler,且日志级别为INFO,由于每一个日志打印器都会直接使用父类的处理器,因此我们之前需要关闭父类然后使用我们自己的处理器。 - -我们通过使用自己日志处理器来自定义级别的信息打印到控制台,当然,日志处理器不仅仅只有控制台打印,我们也可以使用文件处理器来处理日志信息,我们继续添加一个处理器: - -```java -//添加输出到本地文件 -FileHandler fileHandler = new FileHandler("test.log"); -fileHandler.setLevel(Level.WARNING); -logger.addHandler(fileHandler); -``` - -注意,这个时候就有两个日志处理器了,因此控制台和文件的都会生效。如果日志的打印格式我们不喜欢,我们还可以自定义打印格式,比如我们控制台处理器就默认使用的是`SimpleFormatter`,而文件处理器则是使用的`XMLFormatter`,我们可以自定义: - -```java -//使用自定义日志处理器(控制台) -ConsoleHandler handler = new ConsoleHandler(); -handler.setLevel(Level.CONFIG); -handler.setFormatter(new XMLFormatter()); -logger.addHandler(handler); -``` - -我们可以直接配置为想要的打印格式,如果这些格式还不能满足你,那么我们也可以自行实现: - -```java -public static void main(String[] args) throws IOException { - Logger logger = Logger.getLogger(Main.class.getName()); - logger.setUseParentHandlers(false); - - //为了让颜色变回普通的颜色,通过代码块在初始化时将输出流设定为System.out - ConsoleHandler handler = new ConsoleHandler(){{ - setOutputStream(System.out); - }}; - //创建匿名内部类实现自定义的格式 - handler.setFormatter(new Formatter() { - @Override - public String format(LogRecord record) { - SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"); - String time = format.format(new Date(record.getMillis())); //格式化日志时间 - String level = record.getLevel().getName(); // 获取日志级别名称 - // String level = record.getLevel().getLocalizedName(); // 获取本地化名称(语言跟随系统) - String thread = String.format("%10s", Thread.currentThread().getName()); //线程名称(做了格式化处理,留出10格空间) - long threadID = record.getThreadID(); //线程ID - String className = String.format("%-20s", record.getSourceClassName()); //发送日志的类名 - String msg = record.getMessage(); //日志消息 - - //\033[33m作为颜色代码,30~37都有对应的颜色,38是没有颜色,IDEA能显示,但是某些地方可能不支持 - return "\033[38m" + time + " \033[33m" + level + " \033[35m" + threadID - + "\033[38m --- [" + thread + "] \033[36m" + className + "\033[38m : " + msg + "\n"; - } - }); - logger.addHandler(handler); - - logger.info("我是测试消息1..."); - logger.log(Level.INFO, "我是测试消息2..."); - logger.log(Level.WARNING, "我是测试消息3..."); -} -``` - -日志可以设置过滤器,如果我们不希望某些日志信息被输出,我们可以配置过滤规则: - -```java -public static void main(String[] args) throws IOException { - Logger logger = Logger.getLogger(Main.class.getName()); - - //自定义过滤规则 - logger.setFilter(record -> !record.getMessage().contains("普通")); - - logger.log(Level.SEVERE, "严重的错误", new IOException("我就是错误")); - logger.log(Level.WARNING, "警告的内容"); - logger.log(Level.INFO, "普通的信息"); -} -``` - -实际上,整个日志的输出流程如下: - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg-blog.csdnimg.cn%2F20210310214730384.png%3Fx-oss-process%3Dimage%2Fwatermark%2Ctype_ZmFuZ3poZW5naGVpdGk%2Cshadow_10%2Ctext_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzI4NjIzMzc1%2Csize_16%2Ccolor_FFFFFF%2Ct_70&refer=http%3A%2F%2Fimg-blog.csdnimg.cn&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1639566412&t=aec06446b8338134a3dbddfaba9bde69) - -### Properties配置文件 - -Properties文件是Java的一种配置文件,我们之前学习了XML,但是我们发现XML配置文件读取实在是太麻烦,那么能否有一种简单一点的配置文件呢?我们可以使用Properties文件: - -```properties -name=Test -desc=Description -``` - -该文件配置很简单,格式为`配置项=配置值`,我们可以直接通过`Properties`类来将其读取为一个类似于Map一样的对象: - -```java -public static void main(String[] args) throws IOException { - Properties properties = new Properties(); - properties.load(new FileInputStream("test.properties")); - System.out.println(properties); -} -``` - -我们发现,`Properties`类是继承自`Hashtable`,而`Hashtable`是实现的Map接口,也就是说,`Properties`本质上就是一个Map一样的结构,它会把所有的配置项映射为一个Map,这样我们就可以快速地读取对应配置的值了。 - -我们也可以将已经存在的Properties对象放入输出流进行保存,我们这里就不保存文件了,而是直接打印到控制台,我们只需要提供输出流即可: - -```java -public static void main(String[] args) throws IOException { - Properties properties = new Properties(); - // properties.setProperty("test", "lbwnb"); //和put效果一样 - properties.put("test", "lbwnb"); - properties.store(System.out, "????"); - //properties.storeToXML(System.out, "????"); 保存为XML格式 -} -``` - -我们可以通过`System.getProperties()`获取系统的参数,我们来看看: - -```java -public static void main(String[] args) throws IOException { - System.getProperties().store(System.out, "系统信息:"); -} -``` - -### 编写日志配置文件 - -我们可以通过进行配置文件来规定日志打印器的一些默认值: - -```properties -# RootLogger 的默认处理器为 -handlers= java.util.logging.ConsoleHandler -# RootLogger 的默认的日志级别 -.level= CONFIG -``` - -我们来尝试使用配置文件来进行配置: - -```java -public static void main(String[] args) throws IOException { - //获取日志管理器 - LogManager manager = LogManager.getLogManager(); - //读取我们自己的配置文件 - manager.readConfiguration(new FileInputStream("logging.properties")); - //再获取日志打印器 - Logger logger = Logger.getLogger(Main.class.getName()); - logger.log(Level.CONFIG, "我是一条日志信息"); //通过自定义配置文件,我们发现默认级别不再是INFO了 -} -``` - -我们也可以去修改`ConsoleHandler`的默认配置: - -```properties -# 指定默认日志级别 -java.util.logging.ConsoleHandler.level = ALL -# 指定默认日志消息格式 -java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter -# 指定默认的字符集 -java.util.logging.ConsoleHandler.encoding = UTF-8 -``` - -其实,我们阅读`ConsoleHandler`的源码就会发现,它就是通过读取配置文件来进行某些参数设置: - -```java -// Private method to configure a ConsoleHandler from LogManager -// properties and/or default values as specified in the class -// javadoc. -private void configure() { - LogManager manager = LogManager.getLogManager(); - String cname = getClass().getName(); - - setLevel(manager.getLevelProperty(cname +".level", Level.INFO)); - setFilter(manager.getFilterProperty(cname +".filter", null)); - setFormatter(manager.getFormatterProperty(cname +".formatter", new SimpleFormatter())); - try { - setEncoding(manager.getStringProperty(cname +".encoding", null)); - } catch (Exception ex) { - try { - setEncoding(null); - } catch (Exception ex2) { - // doing a setEncoding with null should always work. - // assert false; - } - } -} -``` - -### 使用Lombok快速开启日志 - -我们发现,如果我们现在需要全面使用日志系统,而不是传统的直接打印,那么就需要在每个类都去编写获取Logger的代码,这样显然是很冗余的,能否简化一下这个流程呢? - -前面我们学习了Lombok,我们也体会到Lombok给我们带来的便捷,我们可以通过一个注解快速生成构造方法、Getter和Setter,同样的,Logger也是可以使用Lombok快速生成的。 - -```java -@Log -public class Main { - public static void main(String[] args) { - System.out.println("自动生成的Logger名称:"+log.getName()); - log.info("我是日志信息"); - } -} -``` - -只需要添加一个`@Log`注解即可,添加后,我们可以直接使用一个静态变量log,而它就是自动生成的Logger。我们也可以手动指定名称: - -```java -@Log(topic = "打工是不可能打工的") -public class Main { - public static void main(String[] args) { - System.out.println("自动生成的Logger名称:"+log.getName()); - log.info("我是日志信息"); - } -} -``` - -### Mybatis日志系统 - -Mybatis也有日志系统,它详细记录了所有的数据库操作等,但是我们在前面的学习中没有开启它,现在我们学习了日志之后,我们就可以尝试开启Mybatis的日志系统,来监控所有的数据库操作,要开启日志系统,我们需要进行配置: - -```xml - -``` - -`logImpl`包括很多种配置项,包括 SLF4J | LOG4J | LOG4J2 | JDK_LOGGING | COMMONS_LOGGING | STDOUT_LOGGING | NO_LOGGING,而默认情况下是未配置,也就是说不打印。我们这里将其设定为STDOUT_LOGGING表示直接使用标准输出将日志信息打印到控制台,我们编写一个测试案例来看看效果: - -```java -public class TestMain { - - private SqlSessionFactory sqlSessionFactory; - @Before - public void before(){ - try { - sqlSessionFactory = new SqlSessionFactoryBuilder() - .build(new FileInputStream("mybatis-config.xml")); - } catch (FileNotFoundException e) { - e.printStackTrace(); - } - } - - @Test - public void test(){ - try(SqlSession sqlSession = sqlSessionFactory.openSession(true)){ - TestMapper mapper = sqlSession.getMapper(TestMapper.class); - System.out.println(mapper.getStudentBySidAndSex(1, "男")); - System.out.println(mapper.getStudentBySidAndSex(1, "男")); - } - } -} -``` - -我们发现,两次获取学生信息,只有第一次打开了数据库连接,而第二次并没有。 - -现在我们学习了日志系统,那么我们来尝试使用日志系统输出Mybatis的日志信息: - -```xml - -``` - -将其配置为JDK_LOGGING表示使用JUL进行日志打印,因为Mybatis的日志级别都比较低,因此我们需要设置一下`logging.properties`默认的日志级别: - -```properties -handlers= java.util.logging.ConsoleHandler -.level= ALL -java.util.logging.ConsoleHandler.level = ALL -``` - -代码编写如下: - -```java -@Log -public class TestMain { - - private SqlSessionFactory sqlSessionFactory; - @Before - public void before(){ - try { - sqlSessionFactory = new SqlSessionFactoryBuilder() - .build(new FileInputStream("mybatis-config.xml")); - LogManager manager = LogManager.getLogManager(); - manager.readConfiguration(new FileInputStream("logging.properties")); - } catch (IOException e) { - e.printStackTrace(); - } - } - - @Test - public void test(){ - try(SqlSession sqlSession = sqlSessionFactory.openSession(true)){ - TestMapper mapper = sqlSession.getMapper(TestMapper.class); - log.info(mapper.getStudentBySidAndSex(1, "男").toString()); - log.info(mapper.getStudentBySidAndSex(1, "男").toString()); - } - } -} -``` - -但是我们发现,这样的日志信息根本没法看,因此我们需要修改一下日志的打印格式,我们自己创建一个格式化类: - -```java -public class TestFormatter extends Formatter { - @Override - public String format(LogRecord record) { - SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"); - String time = format.format(new Date(record.getMillis())); //格式化日志时间 - return time + " : " + record.getMessage() + "\n"; - } -} -``` - -现在再来修改一下默认的格式化实现: - -```properties -handlers= java.util.logging.ConsoleHandler -.level= ALL -java.util.logging.ConsoleHandler.level = ALL -java.util.logging.ConsoleHandler.formatter = com.test.TestFormatter -``` - -现在就好看多了,当然,我们还可以继续为Mybatis添加文件日志,这里就不做演示了。 - -*** - -## 使用Maven管理项目 - -**注意:**开始之前,看看你C盘空间够不够,最好预留2GB空间以上! - -**吐槽:**很多电脑预装系统C盘都给得巨少,就算不装软件,一些软件的缓存文件也能给你塞满,建议有时间重装一下系统重新分配一下磁盘空间。 - -Maven 翻译为"专家"、"内行",是 Apache 下的一个纯 Java 开发的开源项目。基于项目对象模型(缩写:POM)概念,Maven利用一个中央信息片断能管理一个项目的构建、报告和文档等步骤。Maven 是一个项目管理工具,可以对 Java 项目进行构建、依赖管理。Maven 也可被用于构建和管理各种项目,例如 C#,Ruby,Scala 和其他语言编写的项目。Maven 曾是 Jakarta 项目的子项目,现为由 Apache 软件基金会主持的独立 Apache 项目。 - -通过Maven,可以帮助我们做: - -* 项目的自动构建,包括代码的编译、测试、打包、安装、部署等操作。 -* 依赖管理,项目使用到哪些依赖,可以快速完成导入。 - -我们之前并没有讲解如何将我们的项目打包为Jar文件运行,同时,我们导入依赖的时候,每次都要去下载对应的Jar包,这样其实是很麻烦的,并且还有可能一个Jar包依赖于另一个Jar包,就像之前使用JUnit一样,因此我们需要一个更加方便的包管理机制。 - -Maven也需要安装环境,但是IDEA已经自带了Maven环境,因此我们不需要再去进行额外的环境安装(无IDEA也能使用Maven,但是配置过程很麻烦,并且我们现在使用的都是IDEA的集成开发环境,所以这里就不讲解Maven命令行操作了)我们直接创建一个新的Maven项目即可。 - -### Maven项目结构 - -我们可以来看一下,一个Maven项目和我们普通的项目有什么区别: - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg-blog.csdnimg.cn%2Fimg_convert%2F910235ebc812ba94abb0f762e3914f67.png&refer=http%3A%2F%2Fimg-blog.csdnimg.cn&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1639621411&t=2a62e7ef9b056d8cbe772e34fea0cc6f) - -那么首先,我们需要了解一下POM文件,它相当于是我们整个Maven项目的配置文件,它也是使用XML编写的: - -```xml - - - 4.0.0 - - org.example - MavenTest - 1.0-SNAPSHOT - - - 8 - 8 - - - -``` - -我们可以看到,Maven的配置文件是以`project`为根节点,而`modelVersion`定义了当前模型的版本,一般是4.0.0,我们不用去修改。 - -`groupId`、`artifactId`、`version`这三个元素合在一起,用于唯一区别每个项目,别人如果需要将我们编写的代码作为依赖,那么就必须通过这三个元素来定位我们的项目,我们称为一个项目的基本坐标,所有的项目一般都有自己的Maven坐标,因此我们通过Maven导入其他的依赖只需要填写这三个基本元素就可以了,无需再下载Jar文件,而是Maven自动帮助我们下载依赖并导入。 - -* `groupId` 一般用于指定组名称,命名规则一般和包名一致,比如我们这里使用的是`org.example`,一个组下面可以有很多个项目。 -* `artifactId` 一般用于指定项目在当前组中的唯一名称,也就是说在组中用于区分于其他项目的标记。 -* `version` 代表项目版本,随着我们项目的开发和改进,版本号也会不断更新,就像LOL一样,每次赛季更新都会有一个大版本更新,我们的Maven项目也是这样,我们可以手动指定当前项目的版本号,其他人使用我们的项目作为依赖时,也可以根本版本号进行选择(这里的SNAPSHOT代表快照,一般表示这是一个处于开发中的项目,正式发布项目一般只带版本号) - -`properties`中一般都是一些变量和选项的配置,我们这里指定了JDK的源代码和编译版本为1.8,无需进行修改。 - -### Maven依赖导入 - -现在我们尝试使用Maven来帮助我们快速导入依赖,我们需要导入之前的JDBC驱动依赖、JUnit依赖、Mybatis依赖、Lombok依赖,那么如何使用Maven来管理依赖呢? - -我们可以创建一个`dependencies`节点: - -```xml - - //里面填写的就是所有的依赖 - -``` - -那么现在就可以向节点中填写依赖了,那么我们如何知道每个依赖的坐标呢?我们可以在:https://mvnrepository.com/ 进行查询(可能打不开,建议用流量,或是直接百度某个项目的Maven依赖),我们直接搜索lombok即可,打开后可以看到已经给我们写出了依赖的坐标: - -```xml - - org.projectlombok - lombok - 1.18.22 - provided - -``` - -我们直接将其添加到`dependencies`节点中即可,现在我们来编写一个测试用例看看依赖导入成功了没有: - -```java -public class Main { - public static void main(String[] args) { - Student student = new Student("小明", 18); - System.out.println(student); - } -} -``` - -```java -@Data -@AllArgsConstructor -public class Student { - String name; - int age; -} -``` - -项目运行成功,表示成功导入了依赖。那么,Maven是如何进行依赖管理呢,以致于如此便捷的导入依赖,我们来看看Maven项目的依赖管理流程: - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimage.bubuko.com%2Finfo%2F201901%2F20190106202802893827.png&refer=http%3A%2F%2Fimage.bubuko.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1639624645&t=75fdf146baa915fbba88918895f92b81) - -通过流程图我们得知,一个项目依赖一般是存储在中央仓库中,也有可能存储在一些其他的远程仓库(私服),几乎所有的依赖都被放到了中央仓库中,因此,Maven可以直接从中央仓库中下载大部分的依赖(Maven第一次导入依赖是需要联网的),远程仓库中下载之后 ,会暂时存储在本地仓库,我们会发现我们本地存在一个`.m2`文件夹,这就是Maven本地仓库文件夹,默认建立在C盘,如果你C盘空间不足,会出现问题! - -在下次导入依赖时,如果Maven发现本地仓库中就已经存在某个依赖,那么就不会再去远程仓库下载了。 - -可能在导入依赖时,小小伙伴们会出现卡顿的问题,我们建议配置一下IDEA自带的Maven插件远程仓库地址,我们打开IDEA的安装目录,找到`安装根目录/plugins/maven/lib/maven3/conf`文件夹,找到`settings.xml`文件,打开编辑: - -找到mirros标签,添加以下内容: - -```xml - - nexus-aliyun - * - Nexus aliyun - http://maven.aliyun.com/nexus/content/groups/public - -``` - -这样,我们就将默认的远程仓库地址(国外),配置为国内的阿里云仓库地址了(依赖的下载速度就会快起来了) - -### Maven依赖作用域 - -除了三个基本的属性用于定位坐标外,依赖还可以添加以下属性: - -- **type**:依赖的类型,对于项目坐标定义的packaging。大部分情况下,该元素不必声明,其默认值为jar -- **scope**:依赖的范围(作用域,着重讲解) -- **optional**:标记依赖是否可选 -- **exclusions**:用来排除传递性依赖(一个项目有可能依赖于其他项目,就像我们的项目,如果别人要用我们的项目作为依赖,那么就需要一起下载我们项目的依赖,如Lombok) - -我们着重来讲解一下`scope`属性,它决定了依赖的作用域范围: - -* **compile** :为默认的依赖有效范围。如果在定义依赖关系的时候,没有明确指定依赖有效范围的话,则默认采用该依赖有效范围。此种依赖,在编译、运行、测试时均有效。 -* **provided** :在编译、测试时有效,但是在运行时无效,也就是说,项目在运行时,不需要此依赖,比如我们上面的Lombok,我们只需要在编译阶段使用它,编译完成后,实际上已经转换为对应的代码了,因此Lombok不需要在项目运行时也存在。 -* **runtime** :在运行、测试时有效,但是在编译代码时无效。比如我们如果需要自己写一个JDBC实现,那么肯定要用到JDK为我们指定的接口,但是实际上在运行时是不用自带JDK的依赖,因此只保留我们自己写的内容即可。 -* **test** :只在测试时有效,例如:JUnit,我们一般只会在测试阶段使用JUnit,而实际项目运行时,我们就用不到测试了,那么我们来看看,导入JUnit的依赖: - -同样的,我们可以在网站上搜索Junit的依赖,我们这里导入最新的JUnit5作为依赖: - -```xml - - org.junit.jupiter - junit-jupiter - 5.8.1 - test - -``` - -我们所有的测试用例全部编写到Maven项目给我们划分的test目录下,位于此目录下的内容不会在最后被打包到项目中,只用作开发阶段测试使用: - -```java -public class MainTest { - - @Test - public void test(){ - System.out.println("测试"); - //Assert在JUnit5时名称发生了变化Assertions - Assertions.assertArrayEquals(new int[]{1, 2, 3}, new int[]{1, 2}); - } -} -``` - -因此,一般仅用作测试的依赖如JUnit只保留在测试中即可,那么现在我们再来添加JDBC和Mybatis的依赖: - -```xml - - mysql - mysql-connector-java - 8.0.27 - - - org.mybatis - mybatis - 3.5.7 - -``` - -我们发现,Maven还给我们提供了一个`resource`文件夹,我们可以将一些静态资源,比如配置文件,放入到这个文件夹中,项目在打包时会将资源文件夹中文件一起打包的Jar中,比如我们在这里编写一个Mybatis的配置文件: - -```xml - - - - - - - - - - - - - - - - - - - - - - - - - - - -``` - -现在我们创建一下测试用例,顺便带大家了解一下Junit5的一些比较方便的地方: - -```java -public class MainTest { - - //因为配置文件位于内部,我们需要使用Resources类的getResourceAsStream来获取内部的资源文件 - private static SqlSessionFactory factory; - - //在JUnit5中@Before被废弃,它被细分了: - @BeforeAll // 一次性开启所有测试案例只会执行一次 (方法必须是static) - // @BeforeEach 一次性开启所有测试案例每个案例开始之前都会执行一次 - @SneakyThrows - public static void before(){ - factory = new SqlSessionFactoryBuilder() - .build(Resources.getResourceAsStream("mybatis.xml")); - } - - - @DisplayName("Mybatis数据库测试") //自定义测试名称 - @RepeatedTest(3) //自动执行多次测试 - public void test(){ - try (SqlSession sqlSession = factory.openSession(true)){ - TestMapper testMapper = sqlSession.getMapper(TestMapper.class); - System.out.println(testMapper.getStudentBySid(1)); - } - } -} -``` - -那么就有人提问了,如果我需要的依赖没有上传的远程仓库,而是只有一个Jar怎么办呢?我们可以使用第四种作用域: - -* **system**:作用域和provided是一样的,但是它不是从远程仓库获取,而是直接导入本地Jar包: - -```xml - - javax.jntm - lbwnb - 2.0 - system - C://学习资料/4K高清无码/test.jar - -``` - -比如上面的例子,如果scope为system,那么我们需要添加一个systemPath来指定jar文件的位置,这里就不再演示了。 - -### Maven可选依赖 - -当项目中的某些依赖不希望被使用此项目作为依赖的项目使用时,我们可以给依赖添加`optional`标签表示此依赖是可选的,默认在导入依赖时,不会导入可选的依赖: - -```xml -true -``` - -比如Mybatis的POM文件中,就存在大量的可选依赖: - -```xml - - org.slf4j - slf4j-api - 1.7.30 - true - - - org.slf4j - slf4j-log4j12 - 1.7.30 - true - - - log4j - log4j - 1.2.17 - true - - ... -``` - -由于Mybatis要支持多种类型的日志,需要用到很多种不同的日志框架,因此需要导入这些依赖来做兼容,但是我们项目中并不一定会使用这些日志框架作为Mybatis的日志打印器,因此这些日志框架仅Mybatis内部做兼容需要导入使用,而我们可以选择不使用这些框架或是选择其中一个即可,也就是说我们导入Mybatis之后想用什么日志框架再自己加就可以了。 - -### Maven排除依赖 - -我们了解了可选依赖,现在我们可以让使用此项目作为依赖的项目默认不使用可选依赖,但是如果存在那种不是可选依赖,但是我们导入此项目有不希望使用此依赖该怎么办呢,这个时候我们就可以通过排除依赖来防止添加不必要的依赖: - -```xml - - org.junit.jupiter - junit-jupiter - 5.8.1 - test - - - org.junit.jupiter - junit-jupiter-engine - - - -``` - -我们这里演示了排除JUnit的一些依赖,我们可以在外部库中观察排除依赖之后和之前的效果。 - -### Maven继承关系 - -一个Maven项目可以继承自另一个Maven项目,比如多个子项目都需要父项目的依赖,我们就可以使用继承关系来快速配置。 - -我们右键左侧栏,新建一个模块,来创建一个子项目: - -```xml - - - - MavenTest - org.example - 1.0-SNAPSHOT - - 4.0.0 - - ChildModel - - - 8 - 8 - - - -``` - -我们可以看到,IDEA默认给我们添加了一个parent节点,表示此Maven项目是父Maven项目的子项目,子项目直接继承父项目的`groupId`,子项目会直接继承父项目的所有依赖,除非依赖添加了optional标签,我们来编写一个测试用例尝试一下: - -```java -import lombok.extern.java.Log; - -@Log -public class Main { - public static void main(String[] args) { - log.info("我是日志信息"); - } -} -``` - -可以看到,子项目也成功继承了Lombok依赖。 - -我们还可以让父Maven项目统一管理所有的依赖,包括版本号等,子项目可以选取需要的作为依赖,而版本全由父项目管理,我们可以将`dependencies`全部放入`dependencyManagement`节点,这样父项目就完全作为依赖统一管理。 - -```xml - - - - org.projectlombok - lombok - 1.18.22 - provided - - - org.junit.jupiter - junit-jupiter - 5.8.1 - test - - - mysql - mysql-connector-java - 8.0.27 - - - org.mybatis - mybatis - 3.5.7 - - - -``` - -我们发现,子项目的依赖失效了,因为现在父项目没有依赖,而是将所有的依赖进行集中管理,子项目需要什么再拿什么即可,同时子项目无需指定版本,所有的版本全部由父项目决定,子项目只需要使用即可: - -```xml - - - org.projectlombok - lombok - provided - - -``` - -当然,父项目如果还存在dependencies节点的话,里面的内依赖依然是直接继承: - -```xml - - - org.junit.jupiter - junit-jupiter - 5.8.1 - test - - - - - - ... -``` - -### Maven常用命令 - -我们可以看到在IDEA右上角Maven板块中,每个Maven项目都有一个生命周期,实际上这些是Maven的一些插件,每个插件都有各自的功能,比如: - -* `clean`命令,执行后会清理整个`target`文件夹,在之后编写Springboot项目时可以解决一些缓存没更新的问题。 -* `validate`命令可以验证项目的可用性。 -* `compile`命令可以将项目编译为.class文件。 -* `install`命令可以将当前项目安装到本地仓库,以供其他项目导入作为依赖使用 -* `verify`命令可以按顺序执行每个默认生命周期阶段(`validate`,`compile`,`package`等) - -### Maven测试项目 - -通过使用`test`命令,可以一键测试所有位于test目录下的测试案例,请注意有以下要求: - -* 测试类的名称必须是以`Test`结尾,比如`MainTest` -* 测试方法上必须标注`@Test`注解,实测`@RepeatedTest`无效 - -这是由于JUnit5比较新,我们需要重新配置插件升级到高版本,才能完美的兼容Junit5: - -```xml - - - - org.apache.maven.plugins - maven-surefire-plugin - - 2.22.0 - - - -``` - -现在`@RepeatedTest`、`@BeforeAll`也能使用了。 - -### Maven打包项目 - -我们的项目在编写完成之后,要么作为Jar依赖,供其他模型使用,要么就作为一个可以执行的程序,在控制台运行,我们只需要直接执行`package`命令就可以直接对项目的代码进行打包,生成jar文件。 - -当然,以上方式仅适用于作为Jar依赖的情况,如果我们需要打包一个可执行文件,那么我不仅需要将自己编写的类打包到Jar中,同时还需要将依赖也一并打包到Jar中,因为我们使用了别人为我们通过的框架,自然也需要运行别人的代码,我们需要使用另一个插件来实现一起打包: - -```xml - - maven-assembly-plugin - 3.1.0 - - - jar-with-dependencies - - - - true - com.test.Main - - - - - - make-assembly - package - - single - - - - -``` - -在打包之前也会执行一次test命令,来保证项目能够正常运行,当测试出现问题时,打包将无法完成,我们也可以手动跳过,选择`执行Maven目标`来手动执行Maven命令,输入`mvn package -Dmaven.test.skip=true `来以跳过测试的方式进行打包。 - -最后得到我们的Jar文件,在同级目录下输入`java -jar xxxx.jar`来运行我们打包好的Jar可执行程序(xxx代表文件名称) - -* `deploy`命令用于发布项目到本地仓库和远程仓库,一般情况下用不到,这里就不做讲解了。 -* `site`命令用于生成当前项目的发布站点,暂时不需要了解。 - -我们之前还讲解了多模块项目,那么多模块下父项目存在一个`packing`打包类型标签,所有的父级项目的packing都为pom,packing默认是jar类型,如果不作配置,maven会将该项目打成jar包。作为父级项目,还有一个重要的属性,那就是modules,通过modules标签将项目的所有子项目引用进来,在build父级项目时,会根据子模块的相互依赖关系整理一个build顺序,然后依次build。 - -*** - -## 实战:基于Mybatis+JUL+Lombok+Maven的图书管理系统(带单元测试) - -项目需求: - -* 在线录入学生信息和书籍信息 -* 查询书籍信息列表 -* 查询学生信息列表 -* 查询借阅信息列表 -* 完整的日志系统 diff --git a/青空笔记/JavaWeb笔记/JavaWeb笔记(二).md b/青空笔记/JavaWeb笔记/JavaWeb笔记(二).md deleted file mode 100644 index 59a8581..0000000 --- a/青空笔记/JavaWeb笔记/JavaWeb笔记(二).md +++ /dev/null @@ -1,588 +0,0 @@ -# 数据库基础 - -数据库是学习JavaWeb的一个前置,只有了解了数据库的操作和使用,我们才能更好地组织和管理网站应用产生的数据。 - -![img](https://img2.baidu.com/it/u=873816781,3605513900&fm=26&fmt=auto) - -## 什么是数据库 - -数据库是数据管理的有效技术,是由一批数据构成的有序集合,这些数据被存放在结构化的数据表里。数据表之间相互关联,反映客观事物间的本质联系。数据库能有效地帮助一个组织或企业科学地管理各类信息资源。简而言之,我们的数据可以交给数据库来帮助我们进行管理,同时数据库能够为我们提供高效的访问性能。 - -在JavaSE学习阶段中,我们学习了如何使用文件I/O来将数据保存到本地,这样就可以将一个数据持久地存储在本地,即使程序重新打开,我们也能加载回上一次的数据,但是当我们的数据变得非常多的时候,这样的方式就显得不太方便了。同时我们如果需要查找众多数据的中的某一个,就只能加载到内存再进行查找,这样显然是很难受的! - -而数据库就是专门做这事的,我们可以快速查找想要的数据,便捷地插入、修改和删除数据,并且数据库不仅能做这些事,还能提供更多便于管理数据和操作数据的功能! - -### 常见的数据库 - -常见的数据库有很多种,包括但不限于: - -* MySQL - 免费,用的最多的,开源数据库,适用于中小型 -* Microsoft SQL Server - 收钱的,但是提供技术支持,适用于Windows Server -* Oracle - 收钱的,大型数据库系统 - -而我们要学习的是MySQL数据,其实无论学习哪种数据库,SQL语句大部分都是通用的,只有少许语法是不通用的,因此我们只需要学习一种数据库其他的也就差不多都会了。 - -### 数据模型 - -数据模型与现实世界中的模型一样,是对现实世界数据特征的一种抽象。实际上,我们之前学习的类就是对现实世界数据的一种抽象,比如一个学生的特征包括姓名,年龄,年级,学号,专业等,这些特征也称为实体的一种属性,属性具有以下特点: - -* 属性不可再分 -* 一个实体的属性可以有很多个 -* 用于唯一区分不同实体的的属性,称为Key,比如每个同学的学号都是不一样的 -* 属性取值可以有一定的约束,比如性别只能是男或是女 - -实体或是属性之间可以具有一定的联系,比如一个老师可以教很多个学生,而学生相对于老师就是被教授的关系;又比如每个同学都有一个学号与其唯一对应,因此学号和学生之间也有一种联系。而像一个老师教多个学生的联系就是一种一对多的联系(1:n),而学号唯一对应,就是一种一对一的联系(1:1);每一个老师不仅可以教多个学生,每一个学生也可以有多个教师,这就是一种多对多的联系(n:m) - -MySQL就是一种关系型数据库,通过使用关系型数据库,我们就可以很好地存储这样带有一定联系的数据。 - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Ffile1.renrendoc.com%2Ffileroot_temp2%2F2020-10%2F17%2F763fb9f3-871d-4f1c-abe7-0a5025cf52a5%2F763fb9f3-871d-4f1c-abe7-0a5025cf52a52.gif&refer=http%3A%2F%2Ffile1.renrendoc.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1637926750&t=31a308e5d8efd32bae55a40c963f459d) - -通过构建一个ER图,我们就能很好地理清不同数据模型之间的关系和特点。 - -## 数据库的创建 - -既然了解了属性和联系,那么我们就来尝试创建一个数据库,并在数据库中添加用于存放数据的表,每一张表都代表一种实体的数据。首先我们要明确,我们需要创建什么样子的表: - -* 学生表:用于存放所有学生的数据,学生(学号,姓名,性别) -* 教师表:用于存放所有教师的数据,教师(教师号,姓名) -* 授课表:用于存放教师与学生的授课信息,授课(学号,教师号) - -其中,标注下划线的属性,作为Key,用于区别于其他实体数据的唯一标记。 - -为了理解起来更加轻松,我们从图形界面操作再讲到SQL语句,请不要着急。我们现在通过Navicat或idea自带的数据库客户端来创建一个数据库和上述三个表。 - -## 数据库的规范化 - -要去设计存放一个实体的表,我们就需要了解数据库的关系规范化,尽可能减少“不好”的关系存在,如何设计一个优良的关系模型是最关键的内容!简而言之,我们要学习一下每一个表该如何去设计。 - -### 第一范式(1NF) - -第一范式是指数据库的每一列都是不可分割的基本数据项,而下面这样的就存在可分割的情况: - -* 学生(姓名,电话号码) - -电话号码实际上包括了`家用座机电话`和`移动电话`,因此它可以被拆分为: - -* 学生(姓名,座机号码,手机号码) - -满足第一范式是关系型数据库最基本的要求! - -### 第二范式(2NF) - -第二范式要求表中必须存在主键,且其他的属性必须完全依赖于主键,比如: - -* 学生(学号,姓名,性别) - -学号是每个学生的唯一标识,每个学生都有着不同的学号,因此此表中存在一个主键,并且每个学生的所有属性都依赖于学号,学号发生改变就代表学生发生改变,姓名和性别都会因此发生改变,所有此表满足第二范式。 - -### 第三范式(3NF) - -在满足第二范式的情况下,所有的属性都不传递依赖于主键,满足第三范式。 - -* 学生借书情况(借阅编号,学生学号,书籍编号,书籍名称,书籍作者) - -实际上书籍编号依赖于借阅编号,而书籍名称和书籍作者依赖于书籍编号,因此存在传递依赖的情况,我们可以将书籍信息进行单独拆分为另一张表: - -* 学生借书情况(借阅编号,学生学号,书籍编号) -* 书籍(书籍编号,书籍名称,书籍作者) - -这样就消除了传递依赖,从而满足第三范式。 - -### BCNF - -BCNF作为第三范式的补充,假设仓库管理关系表为StorehouseManage(仓库ID, 存储物品ID, 管理员ID, 数量),且有一个管理员只在一个仓库工作;一个仓库可以存储多种物品。这个数据库表中存在如下决定关系: - -(仓库ID, 存储物品ID) →(管理员ID, 数量) - -(管理员ID, 存储物品ID) → (仓库ID, 数量) - -所以,(仓库ID, 存储物品ID)和(管理员ID, 存储物品ID)都是StorehouseManage的候选关键字,表中的唯一非关键字段为数量,它是符合第三范式的。但是,由于存在如下决定关系: - -(仓库ID) → (管理员ID) - -(管理员ID) → (仓库ID) - -即存在关键字段决定关键字段的情况,如果修改管理员ID,那么就必须逐一进行修改,所以其不符合BCNF范式。 - -*** - -## 认识SQL语句 - -结构化查询语言(Structured Query Language)简称SQL,这是一种特殊的语言,它专门用于数据库的操作。每一种数据库都支持SQL,但是他们之间会存在一些细微的差异,因此不同的数据库都存在自己的“方言”。 - -SQL语句不区分大小写(关键字推荐使用大写),它支持多行,并且需要使用`;`进行结尾! - -SQL也支持注释,通过使用`--`或是`#`来编写注释内容,也可以使用`/*`来进行多行注释。 - -我们要学习的就是以下四种类型的SQL语言: - -* 数据查询语言(Data Query Language, DQL)基本结构是由SELECT子句,FROM子句,WHERE子句组成的查询块。 -* 数据操纵语言(Data Manipulation Language, DML)是SQL语言中,负责对数据库对象运行数据访问工作的指令集,以INSERT、UPDATE、DELETE三种指令为核心,分别代表插入、更新与删除,是开发以数据为中心的应用程序必定会使用到的指令。 -* 数据库定义语言DDL(Data Definition Language),是用于描述数据库中要存储的现实世界实体的语言。 -* DCL(Data Control Language)是数据库控制语言。是用来设置或更改数据库用户或角色权限的语句,包括(grant,deny,revoke等)语句。在默认状态下,只有sysadmin,dbcreator,db_owner或db_securityadmin等人员才有权力执行DCL。 - -我们平时所说的CRUD其实就是增删改查(Create/Retrieve/Update/Delete) - -*** - -## 数据库定义语言(DDL) - -### 数据库操作 - -我们可以通过`create database`来创建一个数据库: - -```sql -create database 数据库名 -``` - -为了能够支持中文,我们在创建时可以设定编码格式: - -```sql -CREATE DATABASE IF NOT EXISTS 数据库名 DEFAULT CHARSET utf8 COLLATE utf8_general_ci; -``` - -如果我们创建错误了,我们可以将此数据库删除,通过使用`drop database`来删除一个数据库: - -```sql -drop database 数据库名 -``` - -### 创建表 - -数据库创建完成后,我们一般通过`create table`语句来创建一张表: - -```sql -create table 表名(列名 数据类型[列级约束条件], - 列名 数据类型[列级约束条件], - ... - [,表级约束条件]) -``` - -### SQL数据类型 - -以下的数据类型用于字符串存储: - -* char(n)可以存储任意字符串,但是是固定长度为n,如果插入的长度小于定义长度时,则用空格填充。 -* varchar(n)也可以存储任意数量字符串,长度不固定,但不能超过n,不会用空格填充。 - -以下数据类型用于存储数字: - -* smallint用于存储小的整数,范围在 (-32768,32767) -* int用于存储一般的整数,范围在 (-2147483648,2147483647) -* bigint用于存储大型整数,范围在 (-9,223,372,036,854,775,808,9,223,372,036,854,775,807) -* float用于存储单精度小数 -* double用于存储双精度的小数 - -以下数据类型用于存储时间: - -* date存储日期 -* time存储时间 -* year存储年份 -* datetime用于混合存储日期+时间 - -### 列级约束条件 - -列级约束有六种:主键Primary key、外键foreign key 、唯一 unique、检查 check (MySQL不支持)、默认default 、非空/空值 not null/ null - -### 表级约束条件 - -表级约束有四种:主键、外键、唯一、检查 - -现在我们通过SQL语句来创建我们之前提到的三张表。 - -```sql -[CONSTRAINT <外键名>] FOREIGN KEY 字段名 [,字段名2,…] REFERENCES <主表名> 主键列1 [,主键列2,…] -``` - -### 修改表 - -如果我们想修改表结构,我们可以通过`alter table`来进行修改: - -```sql -ALTER TABLE 表名[ADD 新列名 数据类型[列级约束条件]] - [DROP COLUMN 列名[restrict|cascade]] - [ALTER COLUMN 列名 新数据类型] -``` - -我们可以通过ADD来添加一个新的列,通过DROP来删除一个列,不过我们可以添加restrict或cascade,默认是restrict,表示如果此列作为其他表的约束或视图引用到此列时,将无法删除,而cascade会强制连带引用此列的约束、视图一起删除。还可以通过ALTER来修改此列的属性。 - -### 删除表 - -我们可以通过`drop table`来删除一个表: - -```sql -DROP TABLE 表名[restrict|cascade] -``` - -其中restrict和cascade上面的效果一致。 - -*** - -## 数据库操纵语言(DML) - -前面我们已经学习了如何使用SQL语句来创建、修改、删除数据库以及表,而如何向数据库中插入、删除、更新数据,将是本版块讨论的重点。 - -### 插入数据 - -通过使用`insert into`语句来向数据库中插入一条数据(一条记录): - -```sql -INSERT INTO 表名 VALUES(值1, 值2, 值3) -``` - -如果插入的数据与列一一对应,那么可以省略列名,但是如果希望向指定列上插入数据,就需要给出列名: - -```sql -INSERT INTO 表名(列名1, 列名2) VALUES(值1, 值2) -``` - -我们也可以一次性向数据库中插入多条数据: - -```sql -INSERT INTO 表名(列名1, 列名2) VALUES(值1, 值2), (值1, 值2), (值1, 值2) -``` - -我们来试试看向我们刚刚创建的表中添加三条数据。 - -### 修改数据 - -我们可以通过`update`语句来更新表中的数据: - -```sql -UPDATE 表名 SET 列名=值,... WHERE 条件 -``` - -注意,SQL语句中的等于判断是`=` - -**警告:**如果忘记添加`WHERE`字句来限定条件,将使得整个表中此列的所有数据都被修改! - -### 删除数据 - -我们可以通过使用`delete`来删除表中的数据: - -```sql -DELETE FROM 表名 -``` - -通过这种方式,将删除表中全部数据,我们也可以使用`where`来添加条件,只删除指定的数据: - -```sql -DELETE FROM 表名 WHERE 条件 -``` - -*** - -## 数据库查询语言(DQL) - -数据库的查询是我们整个数据库学习中的重点内容,面对数据库中庞大的数据,该如何去寻找我们想要的数据,就是我们主要讨论的问题。 - -### 单表查询 - -单表查询是最简单的一种查询,我们只需要在一张表中去查找数据即可,通过使用`select`语句来进行单表查询: - -```sql --- 指定查询某一列数据 -SELECT 列名[,列名] FROM 表名 --- 会以别名显示此列 -SELECT 列名 别名 FROM 表名 --- 查询所有的列数据 -SELECT * FROM 表名 --- 只查询不重复的值 -SELECT DISTINCT 列名 FROM 表名 -``` - -我们也可以添加`where`字句来限定查询目标: - -```sql -SELECT * FROM 表名 WHERE 条件 -``` - -### 常用查询条件 - -* 一般的比较运算符,包括=、>、<、>=、<=、!=等。 -* 是否在集合中:in、not in -* 字符模糊匹配:like,not like -* 多重条件连接查询:and、or、not - -我们来尝试使用一下上面这几种条件。 - -### 排序查询 - -我们可以通过`order by`来将查询结果进行排序: - -```sql -SELECT * FROM 表名 WHERE 条件 ORDER BY 列名 ASC|DESC -``` - -使用ASC表示升序排序,使用DESC表示降序排序,默认为升序。 - -我们也可以可以同时添加多个排序: - -```sql -SELECT * FROM 表名 WHERE 条件 ORDER BY 列名1 ASC|DESC, 列名2 ASC|DESC -``` - -这样会先按照列名1进行排序,每组列名1相同的数据再按照列名2排序。 - -### 聚集函数 - -聚集函数一般用作统计,包括: - -* `count([distinct]*)`统计所有的行数(distinct表示去重再统计,下同) -* `count([distinct]列名)`统计某列的值总和 -* `sum([distinct]列名)`求一列的和(注意必须是数字类型的) -* `avg([distinct]列名)`求一列的平均值(注意必须是数字类型) -* `max([distinct]列名)`求一列的最大值 -* `min([distinct]列名)`求一列的最小值 - -一般聚集函数是这样使用的: - -```sql -SELECT count(distinct 列名) FROM 表名 WHERE 条件 -``` - -### 分组和分页查询 - -通过使用`group by`来对查询结果进行分组,它需要结合聚合函数一起使用: - -```sql -SELECT sum(*) FROM 表名 WHERE 条件 GROUP BY 列名 -``` - -我们还可以添加`having`来限制分组条件: - -```sql -SELECT sum(*) FROM 表名 WHERE 条件 GROUP BY 列名 HAVING 约束条件 -``` - -我们可以通过`limit`来限制查询的数量,只取前n个结果: - -```sql -SELECT * FROM 表名 LIMIT 数量 -``` - -我们也可以进行分页: - -```sql -SELECT * FROM 表名 LIMIT 起始位置,数量 -``` - -### 多表查询 - -多表查询是同时查询的两个或两个以上的表,多表查询会提通过连接转换为单表查询。 - -```sql -SELECT * FROM 表1, 表2 -``` - -直接这样查询会得到两张表的笛卡尔积,也就是每一项数据和另一张表的每一项数据都结合一次,会产生庞大的数据。 - -```sql -SELECT * FROM 表1, 表2 WHERE 条件 -``` - -这样,只会从笛卡尔积的结果中得到满足条件的数据。 - -**注意:**如果两个表中都带有此属性吗,需要添加表名前缀来指明是哪一个表的数据。 - -### 自身连接查询 - -自身连接,就是将表本身和表进行笛卡尔积计算,得到结果,但是由于表名相同,因此要先起一个别名: - -```sql -SELECT * FROM 表名 别名1, 表名 别名2 -``` - -其实自身连接查询和前面的是一样的,只是连接对象变成自己和自己了。 - -### 外连接查询 - -外连接就是专门用于联合查询情景的,比如现在有一个存储所有用户的表,还有一张用户详细信息的表,我希望将这两张表结合到一起来查看完整的数据,我们就可以通过使用外连接来进行查询,外连接有三种方式: - -* 通过使用`inner join`进行内连接,只会返回两个表满足条件的交集部分: - -![在这里插入图片描述](https://img-blog.csdnimg.cn/2019053022120536.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80Mzg1ODIwMQ==,size_16,color_FFFFFF,t_70) - -* 通过使用`left join`进行左连接,不仅会返回两个表满足条件的交集部分,也会返回左边表中的全部数据,而在右表中缺失的数据会使用`null`来代替(右连接`right join`同理,只是反过来而已,这里就不再介绍了): - -![在这里插入图片描述](https://img-blog.csdnimg.cn/20190530221543230.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80Mzg1ODIwMQ==,size_16,color_FFFFFF,t_70) - -### 嵌套查询 - -我们可以将查询的结果作为另一个查询的条件,比如: - -```sql -SELECT * FROM 表名 WHERE 列名 = (SELECT 列名 FROM 表名 WHERE 条件) -``` - -我们来再次尝试编写一下在最开始我们查找某教师所有学生的SQL语句。 - -*** - -## 数据库控制语言(DCL) - -庞大的数据库不可能由一个人来管理,我们需要更多的用户来一起管理整个数据库。 - -### 创建用户 - -我们可以通过`create user`来创建用户: - -```sql -CREATE USER 用户名 identified by 密码; -``` - -也可以不带密码: - -```sql -CREATE USER 用户名; -``` - -我们可以通过@来限制用户登录的登录IP地址,`%`表示匹配所有的IP地址,默认使用的就是任意IP地址。 - -### 登陆用户 - -首先需要添加一个环境变量,然后我们通过cmd去登陆mysql: - -```sql -login -u 用户名 -p -``` - -输入密码后即可登陆此用户,我们输入以下命令来看看能否访问所有数据库: - -```sql -show databases; -``` - -我们发现,虽然此用户能够成功登录,但是并不能查看完整的数据库列表,这是因为此用户还没有权限! - -### 用户授权 - -我们可以通过使用`grant`来为一个数据库用户进行授权: - -```sql -grant all|权限1,权限2...(列1,...) on 数据库.表 to 用户 [with grant option] -``` - -其中all代表授予所有权限,当数据库和表为`*`,代表为所有的数据库和表都授权。如果在最后添加了`with grant option`,那么被授权的用户还能将已获得的授权继续授权给其他用户。 - -我们可以使用`revoke`来收回一个权限: - -```sql -revoke all|权限1,权限2...(列1,...) on 数据库.表 from 用户 -``` - -*** - -## 视图 - -视图本质就是一个查询的结果,不过我们每次都可以通过打开视图来按照我们想要的样子查看数据。既然视图本质就是一个查询的结果,那么它本身就是一个虚表,并不是真实存在的,数据实际上还是存放在原来的表中。 - -我们可以通过`create view`来创建视图; - -```sql -CREATE VIEW 视图名称(列名) as 子查询语句 [WITH CHECK OPTION]; -``` - -WITH CHECK OPTION是指当创建后,如果更新视图中的数据,是否要满足子查询中的条件表达式,不满足将无法插入,创建后,我们就可以使用`select`语句来直接查询视图上的数据了,因此,还能在视图的基础上,导出其他的视图。 - -1. 若视图是由两个以上基本表导出的,则此视图不允许更新。 -2. 若视图的字段来自字段表达式或常数,则不允许对此视图执行INSERT和UPDATE操作,但允许执行DELETE操作。 -3. 若视图的字段来自集函数,则此视图不允许更新。 -4. 若视图定义中含有GROUP BY子句,则此视图不允许更新。 -5. 若视图定义中含有DISTINCT短语,则此视图不允许更新。 -6. 若视图定义中有嵌套查询,并且内层查询的FROM子句中涉及的表也是导出该视图的基本表,则此视图不允许更新。例如将成绩在平均成绩之上的元组定义成一个视图GOOD_SC: CREATE VIEW GOOD_SC AS SELECT Sno, Cno, Grade FROM SC WHERE Grade > (SELECT AVG(Grade) FROM SC);   导出视图GOOD_SC的基本表是SC,内层查询中涉及的表也是SC,所以视图GOOD_SC是不允许更新的。 -7. 一个不允许更新的视图上定义的视图也不允许更新 - -通过`drop`来删除一个视图: - -```sql -drop view apptest -``` - -*** - -## 索引 - -在数据量变得非常庞大时,通过创建索引,能够大大提高我们的查询效率,就像Hash表一样,它能够快速地定位元素存放的位置,我们可以通过下面的命令创建索引: - -```sql --- 创建索引 -CREATE INDEX 索引名称 ON 表名 (列名) --- 查看表中的索引 -show INDEX FROM student -``` - -我们也可以通过下面的命令删除一个索引: - -```sql -drop index 索引名称 on 表名 -``` - -虽然添加索引后会使得查询效率更高,但是我们不能过度使用索引,索引为我们带来高速查询效率的同时,也会在数据更新时产生额外建立索引的开销,同时也会占用磁盘资源。 - -*** - -## 触发器 - -触发器就像其名字一样,在某种条件下会自动触发,在`select`/`update`/`delete`时,会自动执行我们预先设定的内容,触发器通常用于检查内容的安全性,相比直接添加约束,触发器显得更加灵活。 - -触发器所依附的表称为基本表,当触发器表上发生`select`/`update`/`delete`等操作时,会自动生成两个临时的表(new表和old表,只能由触发器使用) - -比如在`insert`操作时,新的内容会被插入到new表中;在`delete`操作时,旧的内容会被移到old表中,我们仍可在old表中拿到被删除的数据;在`update`操作时,旧的内容会被移到old表中,新的内容会出现在new表中。 - -```sql -CREATE TRIGGER 触发器名称 [BEFORE|AFTER] [INSERT|UPDATE|DELETE] ON 表名/视图名 FOR EACH ROW DELETE FROM student WHERE student.sno = new.sno -``` - - FOR EACH ROW表示针对每一行都会生效,无论哪行进行指定操作都会执行触发器! - -通过下面的命令来查看触发器: - -```sql -SHOW TRIGGERS -``` - -如果不需要,我们就可以删除此触发器: - -```sql -DROP TRIGGER 触发器名称 -``` - -*** - -## 事务 - -当我们要进行的操作非常多时,比如要依次删除很多个表的数据,我们就需要执行大量的SQL语句来完成,这些数据库操作语句就可以构成一个事务!只有Innodb引擎支持事务,我们可以这样来查看支持的引擎: - -```sql -SHOW ENGINES; -``` - -MySQL默认采用的是Innodb引擎,我们也可以去修改为其他的引擎。 - -事务具有以下特性: - -- **原子性:**一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。 -- **一致性:**在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发性地完成预定的工作。 -- **隔离性:**数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交(Read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(Serializable)。 -- **持久性:**事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。 - -我们通过以下例子来探究以下事务: - -```sql -begin; #开始事务 -... -rollback; #回滚事务 -savepoint 回滚点; #添加回滚点 -rollback to 回滚点; #回滚到指定回滚点 -... -commit; #提交事务 --- 一旦提交,就无法再进行回滚了! -``` - -*** - -## 选学内容 - -**函数**和**存储过程**并没有包含在我们的教程当中,但是这并不代表它们就不重要,通过学习它们能够让你的数据库管理能力更上一层楼,它们能够捆绑一组SQL语句运行,并且可以反复使用,大大提高工作效率。 - diff --git a/青空笔记/JavaWeb笔记/JavaWeb笔记(五).md b/青空笔记/JavaWeb笔记/JavaWeb笔记(五).md deleted file mode 100644 index 272aae7..0000000 --- a/青空笔记/JavaWeb笔记/JavaWeb笔记(五).md +++ /dev/null @@ -1,1847 +0,0 @@ -# JavaWeb后端 - -经过前面的学习,现在终于可以正式进入到后端的学习当中,不过,我们还是需要再系统地讲解一下HTTP通信基础知识,它是我们学习JavaWeb的基础知识,我们之前已经学习过TCP通信,而HTTP实际上是基于TCP协议之上的应用层协议,因此理解它并不难理解。 - -打好基础是关键!为什么要去花费时间来讲解计算机网络基础,我们学习一门技术,如果仅仅是知道如何使用却不知道其原理,那么就成了彻头彻尾的“码农”,只知道搬运代码实现功能,却不知道这行代码的执行流程,在遇到一些问题的时候就不知道如何解决,无论是知识层面还是应用层面都得不到提升。 - -无论怎么样,我们都要明确,我们学习JavaWeb的最终目的是为了搭建一个网站,并且让用户能访问我们的网站并在我们的网站上做一些事情。 - -## 计算机网络基础 - -在计算机网络(谢希仁 第七版 第264页)中,是这样描述万维网的: - -> 万维网(World Wide Web)并非是某种特殊的计算机网络,万维网是一个大规模的联机式信息储藏所,英文简称`Web`,万维网用**链接**的方法,能够非常方便地从互联网上的一个站点访问另一个站点,从而主动地按需求获取丰富的信息。 - -这句话说的非常官方,但是也蕴藏着许多的信息,首先它指明,我们的互联网上存在许许多多的服务器,而我们通过访问这些服务器就能快速获取服务器为我们提供的信息(比如打开百度就能展示搜索、打开小破站能刷视频、打开微博能查看实时热点)而这些服务器就是由不同的公司在运营。 - -其次,我们通过浏览器,只需要输入对应的网址或是点击页面中的一个链接,就能够快速地跳转到另一个页面,从而按我们的意愿来访问服务器。 - -而书中是这样描述万维网的工作方式: - -> 万维网以客户服务器的方式工作,浏览器就是安装在用户主机上的万维网客户程序,万维网文档所驻留的主机则运行服务器程序,因此这台主机也称为万维网服务器。**客户程序向服务器程序发出请求,服务器程序向客户程序送回客户所要的万维网文档**,在一个客户程序主窗口上显示出的万维网文档称为页面。 - -上面提到的客户程序其实就是我们电脑上安装的浏览器,而服务端就是我们即将要去学习的Web服务器,也就是说,我们要明白如何搭建一个Web服务器并向用户发送我们提供的Web页面,在浏览器中显示的,一般就是HTML文档被解析后的样子。 - -那么,我们的服务器可能不止一个页面,可能会有很多个页面,那么客户端如何知道该去访问哪个服务器的哪个页面呢?这个时候就需要用到`URL`统一资源定位符。互联网上所有的资源,都有一个唯一确定的URL,比如`http://www.baidu.com` - -URL的格式为: - -> <协议>://<主机>:<端口>/<路径> -> -> 协议是指采用什么协议来访问服务器,不同的协议决定了服务器返回信息的格式,我们一般使用HTTP协议。 -> -> 主机可以是一个域名,也可以是一个IP地址(实际上域名最后会被解析为IP地址进行访问) -> -> 端口是当前服务器上Web应用程序开启的端口,我们前面学习TCP通信的时候已经介绍过了,HTTP协议默认使用80端口,因此有时候可以省略。 -> -> 路径就是我们希望去访问此服务器上的某个文件,不同的路径代表访问不同的资源。 - -我们接着来了解一下什么是HTTP协议: - -> HTTP是面向事务的应用层协议,它是万维网上能够可靠交换文件的重要基础。HTTP不仅传送完成超文本跳转所需的必须信息,而且也传送任何可从互联网上得到的信息,如文本、超文本、声音和图像。 - -实际上我们之前访问百度、访问自己的网站,所有的传输都是以HTTP作为协议进行的。 - -我们来看看HTTP的传输原理: - -> HTTP使用了面向连接的TCP作为运输层协议,保证了数据的可靠传输。HTTP不必考虑数据在传输过程中被丢弃后又怎样被重传。但是HTTP协议本身是无连接的。也就是说,HTTP虽然使用了TCP连接,但是通信的双方在交换HTTP报文之前不需要先建立HTTP连接。1997年以前使用的是HTTP/1.0协议,之后就是HTTP/1.1协议了。 - -那么既然HTTP是基于TCP进行通信的,我们首先来回顾一下TCP的通信原理: - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fstatic.oschina.net%2Fuploads%2Fspace%2F2016%2F0407%2F144257_WTql_2537915.jpg&refer=http%3A%2F%2Fstatic.oschina.net&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1640244422&t=e2c991d149b7ae79d3baa7868633f4d6) - -TCP协议实际上是经历了三次握手再进行通信,也就是说保证整个通信是稳定的,才可以进行数据交换,并且在连接已经建立的过程中,双方随时可以互相发送数据,直到有一方主动关闭连接,这时在进行四次挥手,完成整个TCP通信。 - -而HTTP和TCP并不是一个层次的通信协议,TCP是传输层协议,而HTTP是应用层协议,因此,实际上HTTP的内容会作为TCP协议的报文被封装,并继续向下一层进行传递,而传输到客户端时,会依次进行解包,还原为最开始的HTTP数据。 - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fwww.edatop.com%2Ftech%2Fimages%2Fefans%2Fmcu%2Fmcu-257524hyx0ez3djs.png&refer=http%3A%2F%2Fwww.edatop.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1640244335&t=b0e3e66fdac9f66ab64262a725e041f8) - -HTTP使用TCP协议是为了使得数据传输更加可靠,既然它是依靠TCP协议进行数据传输,那么为什么说它本身是无连接的呢?我们来看一下HTTP的传输过程: - -> 用户在点击鼠标链接某个万维网文档时,HTTP协议首先要和服务器建立TCP连接。这需要使用三报文握手。当建立TCP连接的三报文握手的前两部分完成后(即经过了一个RTT时间后),万维网客户就把HTTP请求报文作为建立TCP连接的三报文握手中的第三个报文的数据,发送给万维网服务器。服务器收到HTTP请求报文后,就把所请求的文档作为响应报文返回给客户。 - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fwww.pianshen.com%2Fimages%2F323%2F7b19a0d1acac11f91ba549001758a393.png&refer=http%3A%2F%2Fwww.pianshen.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1640245028&t=bb9d88a42c52313924edc8a7d937cbf8) - -因此,我们的浏览器请求一个页面,需要两倍的往返时间。 - -最后,我们再来了解一下HTTP的报文结构: - -![img](https://img2.baidu.com/it/u=1539060868,3030092954&fm=26&fmt=auto) - -由客户端向服务端发送是报文称为请求报文,而服务端返回给客户端的称为响应报文,实际上,整个报文全部是以文本形式发送的,通过使用空格和换行来完成分段。 - -现在,我们已经了解了HTTP协议的全部基础知识,那么什么是Web服务器呢,实际上,它就是一个软件,但是它已经封装了所有的HTTP协议层面的操作,我们无需关心如何使用HTTP协议通信,而是直接基于服务器软件进行开发,我们只需要关心我们的页面数据如何展示、前后端如何交互即可。 - -## 认识Tomcat服务器 - -[![Tomcat Home](https://tomcat.apache.org/res/images/tomcat.png)](http://tomcat.apache.org/) - -Tomcat(汤姆猫)就是一个典型的Web应用服务器软件,通过运行Tomcat服务器,我们就可以快速部署我们的Web项目,并交由Tomcat进行管理,我们只需要直接通过浏览器访问我们的项目即可。 - -那么首先,我们需要进行一个简单的环境搭建,我们需要在Tomcat官网下载最新的Tomcat服务端程序:https://tomcat.apache.org/download-10.cgi(下载速度可能有点慢) - -- 下载:64-bit Windows zip - -下载完成后,解压,并放入桌面,接下来需要配置一下环境变量,打开`高级系统设置`,打开`环境变量`,添加一个新的系统变量,变量名称为`JRE_HOME`,填写JDK的安装目录+/jre,比如Zulujdk默认就是:C:\Program Files\Zulu\zulu-8\jre - -设置完成后,我们进入tomcat文件夹bin目录下,并在当前位置打开CMD窗口,将startup.sh拖入窗口按回车运行,如果环境变量配置有误,会提示,若没问题,服务器则正常启动。 - -如果出现乱码,说明编码格式配置有问题,我们修改一下服务器的配置文件,打开`conf`文件夹,找到`logging.properties`文件,这就是日志的配置文件(我们在前面已经给大家讲解过了)将ConsoleHandler的默认编码格式修改为GBK编码格式: - -```properties -java.util.logging.ConsoleHandler.encoding = GBK -``` - -现在重新启动服务器,就可以正常显示中文了。 - -服务器启动成功之后,不要关闭,我们打开浏览器,在浏览器中访问:http://localhost:8080/,Tomcat服务器默认是使用8080端口(可以在配置文件中修改),访问成功说明我们的Tomcat环境已经部署成功了。 - -整个Tomcat目录下,我们已经认识了bin目录(所有可执行文件,包括启动和关闭服务器的脚本)以及conf目录(服务器配置文件目录),那么我们接着来看其他的文件夹: - -* lib目录:Tomcat服务端运行的一些依赖,不用关心。 -* logs目录:所有的日志信息都在这里。 -* temp目录:存放运行时产生的一些临时文件,不用关心。 -* work目录:工作目录,Tomcat会将jsp文件转换为java文件(我们后面会讲到,这里暂时不提及) -* webapp目录:所有的Web项目都在这里,每个文件夹都是一个Web应用程序: - -我们发现,官方已经给我们预设了一些项目了,访问后默认使用的项目为ROOT项目,也就是我们默认打开的网站。 - -我们也可以访问example项目,只需要在后面填写路径即可:http://localhost:8080/examples/,或是docs项目(这个是Tomcat的一些文档)http://localhost:8080/docs/ - -Tomcat还自带管理页面,我们打开:http://localhost:8080/manager,提示需要用户名和密码,由于不知道是什么,我们先点击取消,页面中出现如下内容: - -> You are not authorized to view this page. If you have not changed any configuration files, please examine the file `conf/tomcat-users.xml` in your installation. That file must contain the credentials to let you use this webapp. -> -> For example, to add the `manager-gui` role to a user named `tomcat` with a password of `s3cret`, add the following to the config file listed above. -> -> ``` -> -> -> ``` -> -> Note that for Tomcat 7 onwards, the roles required to use the manager application were changed from the single `manager` role to the following four roles. You will need to assign the role(s) required for the functionality you wish to access. -> -> - `manager-gui` - allows access to the HTML GUI and the status pages -> - `manager-script` - allows access to the text interface and the status pages -> - `manager-jmx` - allows access to the JMX proxy and the status pages -> - `manager-status` - allows access to the status pages only -> -> The HTML interface is protected against CSRF but the text and JMX interfaces are not. To maintain the CSRF protection: -> -> - Users with the `manager-gui` role should not be granted either the `manager-script` or `manager-jmx` roles. -> - If the text or jmx interfaces are accessed through a browser (e.g. for testing since these interfaces are intended for tools not humans) then the browser must be closed afterwards to terminate the session. -> -> For more information - please see the [Manager App How-To](http://localhost:8080/docs/manager-howto.html). - -现在我们按照上面的提示,去配置文件中进行修改: - -```xml - - -``` - -现在再次打开管理页面,已经可以成功使用此用户进行登陆了。登录后,展示给我们的是一个图形化界面,我们可以快速预览当前服务器的一些信息,包括已经在运行的Web应用程序,甚至还可以查看当前的Web应用程序有没有出现内存泄露。 - -同样的,还有一个虚拟主机管理页面,用于一台主机搭建多个Web站点,一般情况下使用不到,这里就不做演示了。 - -我们可以将我们自己的项目也放到webapp文件夹中,这样就可以直接访问到了,我们在webapp目录下新建test文件夹,将我们之前编写的前端代码全部放入其中(包括html文件、js、css、icon等),重启服务器。 - -我们可以直接通过 http://localhost:8080/test/ 来进行访问。 - -*** - -## 使用Maven创建Web项目 - -虽然我们已经可以在Tomcat上部署我们的前端页面了,但是依然只是一个静态页面(每次访问都是同样的样子),那么如何向服务器请求一个动态的页面呢(比如显示我们访问当前页面的时间)这时就需要我们编写一个Web应用程序来实现了,我们需要在用户向服务器发起页面请求时,进行一些处理,再将结果发送给用户的浏览器。 - -**注意:**这里需要使用终极版IDEA,如果你的还是社区版,就很难受了。 - -我们打开IDEA,新建一个项目,选择Java Enterprise(社区版没有此选项!)项目名称随便,项目模板选择Web应用程序,然后我们需要配置Web应用程序服务器,将我们的Tomcat服务器集成到IDEA中。配置很简单,首先点击新建,然后设置Tomcat主目录即可,配置完成后,点击下一步即可,依赖项使用默认即可,然后点击完成,之后IDEA会自动帮助我们创建Maven项目。 - -创建完成后,直接点击右上角即可运行此项目了,但是我们发现,有一个Servlet页面不生效。 - -需要注意的是,Tomcat10以上的版本比较新,Servlet API包名发生了一些变化,因此我们需要修改一下依赖: - -```xml - - jakarta.servlet - jakarta.servlet-api - 5.0.0 - provided - -``` - -注意包名全部从javax改为jakarta,我们需要手动修改一下。 - -感兴趣的可以了解一下为什么名称被修改了: - -> Eclipse基金会在2019年对 Java EE 标准的每个规范进行了重命名,阐明了每个规范在Jakarta EE平台未来的角色。 -> -> 新的名称Jakarta EE是Java EE的第二次重命名。2006年5月,“J2EE”一词被弃用,并选择了Java EE这个名称。在YouTube还只是一家独立的公司的时候,数字2就就从名字中消失了,而且当时冥王星仍然被认为是一颗行星。同样,作为Java SE 5(2004)的一部分,数字2也从J2SE中删除了,那时谷歌还没有上市。 -> -> **因为不能再使用javax名称空间,Jakarta EE提供了非常明显的分界线。** -> -> - Jakarta 9(2019及以后)使用jakarta命名空间。 -> - Java EE 5(2005)到Java EE 8(2017)使用javax命名空间。 -> - Java EE 4使用javax命名空间。 - -我们可以将项目直接打包为war包(默认),打包好之后,放入webapp文件夹,就可以直接运行我们通过Java编写的Web应用程序了,访问路径为文件的名称。 - -## Servlet - -前面我们已经完成了基本的环境搭建,那么现在我们就可以开始来了解我们的第一个重要类——Servlet。 - -它是Java EE的一个标准,大部分的Web服务器都支持此标准,包括Tomcat,就像之前的JDBC一样,由官方定义了一系列接口,而具体实现由我们来编写,最后交给Web服务器(如Tomcat)来运行我们编写的Servlet。 - -那么,它能做什么呢?我们可以通过实现Servlet来进行动态网页响应,使用Servlet,不再是直接由Tomcat服务器发送我们编写好的静态网页内容(HTML文件),而是由我们通过Java代码进行动态拼接的结果,它能够很好地实现动态网页的返回。 - -当然,Servlet并不是专用于HTTP协议通信,也可以用于其他的通信,但是一般都是用于HTTP。 - -### 创建Servlet - -那么如何创建一个Servlet呢,非常简单,我们只需要实现`Servlet`类即可,并添加注解`@WebServlet`来进行注册。 - -```java -@WebServlet("/test") -public class TestServlet implements Servlet { - ...实现接口方法 -} -``` - -我们现在就可以去访问一下我们的页面:http://localhost:8080/test/test - -我们发现,直接访问此页面是没有任何内容的,这是因为我们还没有为该请求方法编写实现,这里先不做讲解,后面我们会对浏览器的请求处理做详细的介绍。 - -除了直接编写一个类,我们也可以在`web.xml`中进行注册,现将类上`@WebServlet`的注解去掉: - -```xml - - test - com.example.webtest.TestServlet - - - test - /test - -``` - -这样的方式也能注册Servlet,但是显然直接使用注解更加方便,因此之后我们一律使用注解进行开发。只有比较新的版本才支持此注解,老的版本是不支持的哦。 - -实际上,Tomcat服务器会为我们提供一些默认的Servlet,也就是说在服务器启动后,即使我们什么都不编写,Tomcat也自带了几个默认的Servlet,他们编写在conf目录下的web.xml中: - -```xml - - - default - / - - - - - jsp - *.jsp - *.jspx - - -``` - -我们发现,默认的Servlet实际上可以帮助我们去访问一些静态资源,这也是为什么我们启动Tomcat服务器之后,能够直接访问webapp目录下的静态页面。 - -我们可以将之前编写的页面放入到webapp目录下,来测试一下是否能直接访问。 - -### 探究Servlet的生命周期 - -我们已经了解了如何注册一个Servlet,那么我们接着来看看,一个Servlet是如何运行的。 - -首先我们需要了解,Servlet中的方法各自是在什么时候被调用的,我们先编写一个打印语句来看看: - -```java -public class TestServlet implements Servlet { - - public TestServlet(){ - System.out.println("我是构造方法!"); - } - - @Override - public void init(ServletConfig servletConfig) throws ServletException { - System.out.println("我是init"); - } - - @Override - public ServletConfig getServletConfig() { - System.out.println("我是getServletConfig"); - return null; - } - - @Override - public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException { - System.out.println("我是service"); - } - - @Override - public String getServletInfo() { - System.out.println("我是getServletInfo"); - return null; - } - - @Override - public void destroy() { - System.out.println("我是destroy"); - } -} -``` - -我们首先启动一次服务器,然后访问我们定义的页面,然后再关闭服务器,得到如下的顺序: - -> 我是构造方法! -> 我是init -> 我是service -> 我是service(出现两次是因为浏览器请求了2次,是因为有一次是请求favicon.ico,浏览器通病) -> -> 我是destroy - -我们可以多次尝试去访问此页面,但是init和构造方法只会执行一次,而每次访问都会执行的是`service`方法,因此,一个Servlet的生命周期为: - -- 首先执行构造方法完成 Servlet 初始化 -- Servlet 初始化后调用 **init ()** 方法。 -- Servlet 调用 **service()** 方法来处理客户端的请求。 -- Servlet 销毁前调用 **destroy()** 方法。 -- 最后,Servlet 是由 JVM 的垃圾回收器进行垃圾回收的。 - -现在我们发现,实际上在Web应用程序运行时,每当浏览器向服务器发起一个请求时,都会创建一个线程执行一次`service`方法,来让我们处理用户的请求,并将结果响应给用户。 - -我们发现`service`方法中,还有两个参数,`ServletRequest`和`ServletResponse`,实际上,用户发起的HTTP请求,就被Tomcat服务器封装为了一个`ServletRequest`对象,我们得到是其实是Tomcat服务器帮助我们创建的一个实现类,HTTP请求报文中的所有内容,都可以从`ServletRequest`对象中获取,同理,`ServletResponse`就是我们需要返回给浏览器的HTTP响应报文实体类封装。 - -那么我们来看看`ServletRequest`中有哪些内容,我们可以获取请求的一些信息: - -```java -@Override -public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException { - //首先将其转换为HttpServletRequest(继承自ServletRequest,一般是此接口实现) - HttpServletRequest request = (HttpServletRequest) servletRequest; - - System.out.println(request.getProtocol()); //获取协议版本 - System.out.println(request.getRemoteAddr()); //获取访问者的IP地址 - System.out.println(request.getMethod()); //获取请求方法 - //获取头部信息 - Enumeration enumeration = request.getHeaderNames(); - while (enumeration.hasMoreElements()){ - String name = enumeration.nextElement(); - System.out.println(name + ": " + request.getHeader(name)); - } -} -``` - -我们发现,整个HTTP请求报文中的所有内容,都可以通过`HttpServletRequest`对象来获取,当然,它的作用肯定不仅仅是获取头部信息,我们还可以使用它来完成更多操作,后面会一一讲解。 - -那么我们再来看看`ServletResponse`,这个是服务端的响应内容,我们可以在这里填写我们想要发送给浏览器显示的内容: - -```java -//转换为HttpServletResponse(同上) -HttpServletResponse response = (HttpServletResponse) servletResponse; -//设定内容类型以及编码格式(普通HTML文本使用text/html,之后会讲解文件传输) -response.setHeader("Content-type", "text/html;charset=UTF-8"); -//获取Writer直接写入内容 -response.getWriter().write("我是响应内容!"); -//所有内容写入完成之后,再发送给浏览器 -``` - -现在我们在浏览器中打开此页面,就能够收到服务器发来的响应内容了。其中,响应头部分,是由Tomcat帮助我们生成的一个默认响应头。 - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fwww.qingruanit.net%2FcatchImages%2F20170218%2F1487385940733020268.png&refer=http%3A%2F%2Fwww.qingruanit.net&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1640328590&t=27d773847d13c6ac21c270379dc25717) - -因此,实际上整个流程就已经很清晰明了了。 - -### 解读和使用HttpServlet - -前面我们已经学习了如何创建、注册和使用Servlet,那么我们继续来深入学习Servlet接口的一些实现类。 - -首先`Servlet`有一个直接实现抽象类`GenericServlet`,那么我们来看看此类做了什么事情。 - -我们发现,这个类完善了配置文件读取和Servlet信息相关的的操作,但是依然没有去实现service方法,因此此类仅仅是用于完善一个Servlet的基本操作,那么我们接着来看`HttpServlet`,它是遵循HTTP协议的一种Servlet,继承自`GenericServlet`,它根据HTTP协议的规则,完善了service方法。 - -在阅读了HttpServlet源码之后,我们发现,其实我们只需要继承HttpServlet来编写我们的Servlet就可以了,并且它已经帮助我们提前实现了一些操作,这样就会给我们省去很多的时间。 - -```java -@Log -@WebServlet("/test") -public class TestServlet extends HttpServlet { - - @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - resp.setContentType("text/html;charset=UTF-8"); - resp.getWriter().write("

恭喜你解锁了全新玩法

"); - } -} -``` - -现在,我们只需要重写对应的请求方式,就可以快速完成Servlet的编写。 - -### @WebServlet注解详解 - -我们接着来看WebServlet注解,我们前面已经得知,可以直接使用此注解来快速注册一个Servlet,那么我们来想细看看此注解还有什么其他的玩法。 - -首先name属性就是Servlet名称,而urlPatterns和value实际上是同样功能,就是代表当前Servlet的访问路径,它不仅仅可以是一个固定值,还可以进行通配符匹配: - -```java -@WebServlet("/test/*") -``` - -上面的路径表示,所有匹配`/test/随便什么`的路径名称,都可以访问此Servlet,我们可以在浏览器中尝试一下。 - -也可以进行某个扩展名称的匹配: - -```java -@WebServlet("*.js") -``` - -这样的话,获取任何以js结尾的文件,都会由我们自己定义的Servlet处理。 - -那么如果我们的路径为`/`呢? - -```java -@WebServlet("/") -``` - -此路径和Tomcat默认为我们提供的Servlet冲突,会直接替换掉默认的,而使用我们的,此路径的意思为,如果没有找到匹配当前访问路径的Servlet,那么久会使用此Servlet进行处理。 - -我们还可以为一个Servlet配置多个访问路径: - -```java -@WebServlet({"/test1", "/test2"}) -``` - -我们接着来看loadOnStartup属性,此属性决定了是否在Tomcat启动时就加载此Servlet,默认情况下,Servlet只有在被访问时才会加载,它的默认值为-1,表示不在启动时加载,我们可以将其修改为大于等于0的数,来开启启动时加载。并且数字的大小决定了此Servlet的启动优先级。 - -```java -@Log -@WebServlet(value = "/test", loadOnStartup = 1) -public class TestServlet extends HttpServlet { - - @Override - public void init() throws ServletException { - super.init(); - log.info("我被初始化了!"); - } - - @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - resp.setContentType("text/html;charset=UTF-8"); - resp.getWriter().write("

恭喜你解锁了全新玩法

"); - } -} -``` - -其他内容都是Servlet的一些基本配置,这里就不详细讲解了。 - -### 使用POST请求完成登陆 - -我们前面已经了解了如何使用Servlet来处理HTTP请求,那么现在,我们就结合前端,来实现一下登陆操作。 - -我们需要修改一下我们的Servlet,现在我们要让其能够接收一个POST请求: - -```java -@Log -@WebServlet("/login") -public class LoginServlet extends HttpServlet { - @Override - protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - req.getParameterMap().forEach((k, v) -> { - System.out.println(k + ": " + Arrays.toString(v)); - }); - } -} -``` - -`ParameterMap`存储了我们发送的POST请求所携带的表单数据,我们可以直接将其遍历查看,浏览器发送了什么数据。 - -现在我们再来修改一下前端: - -```html - -

登录到系统

-
-
-
- -
-
- -
-
- -
-
- -``` - -通过修改form标签的属性,现在我们点击登录按钮,会自动向后台发送一个POST请求,请求地址为当前地址+/login(注意不同路径的写法),也就是我们上面编写的Servlet路径。 - -运行服务器,测试后发现,在点击按钮后,确实向服务器发起了一个POST请求,并且携带了表单中文本框的数据。 - -现在,我们根据已有的基础,将其与数据库打通,我们进行一个真正的用户登录操作,首先修改一下Servlet的逻辑: - -```java -@Override -protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - //首先设置一下响应类型 - resp.setContentType("text/html;charset=UTF-8"); - //获取POST请求携带的表单数据 - Map map = req.getParameterMap(); - //判断表单是否完整 - if(map.containsKey("username") && map.containsKey("password")) { - String username = req.getParameter("username"); - String password = req.getParameter("password"); - - //权限校验(待完善) - }else { - resp.getWriter().write("错误,您的表单数据不完整!"); - } -} -``` - -接下来我们再去编写Mybatis的依赖和配置文件,创建一个表,用于存放我们用户的账号和密码。 - -```xml - - - - - - - - - - - - - - - -``` - -```xml - - org.mybatis - mybatis - 3.5.7 - - - mysql - mysql-connector-java - 8.0.27 - -``` - -配置完成后,在我们的Servlet的init方法中编写Mybatis初始化代码,因为它只需要初始化一次。 - -```java -SqlSessionFactory factory; -@SneakyThrows -@Override -public void init() throws ServletException { - factory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsReader("mybatis-config.xml")); -} -``` - -现在我们创建一个实体类以及Mapper来进行用户信息查询: - -```java -@Data -public class User { - String username; - String password; -} -``` - -```java -public interface UserMapper { - - @Select("select * from users where username = #{username} and password = #{password}") - User getUser(@Param("username") String username, @Param("password") String password); -} -``` - -```xml - - - -``` - -好了,现在完事具备,只欠东风了,我们来完善一下登陆验证逻辑: - -```java -//登陆校验(待完善) -try (SqlSession sqlSession = factory.openSession(true)){ - UserMapper mapper = sqlSession.getMapper(UserMapper.class); - User user = mapper.getUser(username, password); - //判断用户是否登陆成功,若查询到信息则表示存在此用户 - if(user != null){ - resp.getWriter().write("登陆成功!"); - }else { - resp.getWriter().write("登陆失败,请验证您的用户名或密码!"); - } -} -``` - -现在再去浏览器上进行测试吧! - -注册界面其实是同理的,这里就不多做讲解了。 - -### 上传和下载文件 - -首先我们来看看比较简单的下载文件,首先将我们的icon.png放入到resource文件夹中,接着我们编写一个Servlet用于处理文件下载: - -```java -@WebServlet("/file") -public class FileServlet extends HttpServlet { - - @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - resp.setContentType("image/png"); - OutputStream outputStream = resp.getOutputStream(); - InputStream inputStream = Resources.getResourceAsStream("icon.png"); - - } -} -``` - -为了更加快速地编写IO代码,我们可以引入一个工具库: - -```xml - - commons-io - commons-io - 2.6 - -``` - -使用此类库可以快速完成IO操作: - -```java -resp.setContentType("image/png"); -OutputStream outputStream = resp.getOutputStream(); -InputStream inputStream = Resources.getResourceAsStream("icon.png"); -//直接使用copy方法完成转换 -IOUtils.copy(inputStream, outputStream); -``` - -现在我们在前端页面添加一个链接,用于下载此文件: - -```html -
-点我下载高清资源 -``` - -下载文件搞定,那么如何上传一个文件呢? - -首先我们编写前端部分: - -```html -
-
- -
-
- -
-
-``` - -注意必须添加`enctype="multipart/form-data"`,来表示此表单用于文件传输。 - -现在我们来修改一下Servlet代码: - -```java -@MultipartConfig -@WebServlet("/file") -public class FileServlet extends HttpServlet { - - @Override - protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - try(FileOutputStream stream = new FileOutputStream("/Users/nagocoler/Documents/IdeaProjects/WebTest/test.png")){ - Part part = req.getPart("test-file"); - IOUtils.copy(part.getInputStream(), stream); - resp.setContentType("text/html;charset=UTF-8"); - resp.getWriter().write("文件上传成功!"); - } - } -} -``` - -注意,必须添加`@MultipartConfig`注解来表示此Servlet用于处理文件上传请求。 - -现在我们再运行服务器,并将我们刚才下载的文件又上传给服务端。 - -### 使用XHR请求数据 - -现在我们希望,网页中的部分内容,可以动态显示,比如网页上有一个时间,旁边有一个按钮,点击按钮就可以刷新当前时间。 - -这个时候就需要我们在网页展示时向后端发起请求了,并根据后端响应的结果,动态地更新页面中的内容,要实现此功能,就需要用到JavaScript来帮助我们,首先在js中编写我们的XHR请求,并在请求中完成动态更新: - -```js -function updateTime() { - let xhr = new XMLHttpRequest(); - xhr.onreadystatechange = function() { - if (xhr.readyState === 4 && xhr.status === 200) { - document.getElementById("time").innerText = xhr.responseText - } - }; - xhr.open('GET', 'time', true); - xhr.send(); -} -``` - -接着修改一下前端页面,添加一个时间显示区域: - -```html -
-
-
- - -``` - -最后创建一个Servlet用于处理时间更新请求: - -```java -@WebServlet("/time") -public class TimeServlet extends HttpServlet { - - @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy年MM月dd日 HH:mm:ss"); - String date = dateFormat.format(new Date()); - resp.setContentType("text/html;charset=UTF-8"); - resp.getWriter().write(date); - } -} -``` - -现在点击按钮就可以更新了。 - -GET请求也能传递参数,这里做一下演示。 - -### 重定向与请求转发 - -当我们希望用户登录完成之后,直接跳转到网站的首页,那么这个时候,我们就可以使用重定向来完成。当浏览器收到一个重定向的响应时,会按照重定向响应给出的地址,再次向此地址发出请求。 - -实现重定向很简单,只需要调用一个方法即可,我们修改一下登陆成功后执行的代码: - -```java -resp.sendRedirect("time"); -``` - -调用后,响应的状态码会被设置为302,并且响应头中添加了一个Location属性,此属性表示,需要重定向到哪一个网址。 - -现在,如果我们成功登陆,那么服务器会发送给我们一个重定向响应,这时,我们的浏览器会去重新请求另一个网址。这样,我们在登陆成功之后,就可以直接帮助用户跳转到用户首页了。 - -那么我们接着来看请求转发,请求转发其实是一种服务器内部的跳转机制,我们知道,重定向会使得浏览器去重新请求一个页面,而请求转发则是服务器内部进行跳转,它的目的是,直接将本次请求转发给其他Servlet进行处理,并由其他Servlet来返回结果,因此它是在进行内部的转发。 - -```java -req.getRequestDispatcher("/time").forward(req, resp); -``` - -现在,在登陆成功的时候,我们将请求转发给处理时间的Servlet,注意这里的路径规则和之前的不同,我们需要填写Servlet上指明的路径,并且请求转发只能转发到此应用程序内部的Servlet,不能转发给其他站点或是其他Web应用程序。 - -现在再次进行登陆操作,我们发现,返回结果为一个405页面,证明了,我们的请求现在是被另一个Servlet进行处理,并且请求的信息全部被转交给另一个Servlet,由于此Servlet不支持POST请求,因此返回405状态码。 - -那么也就是说,该请求包括请求参数也一起被传递了,那么我们可以尝试获取以下POST请求的参数。 - -现在我们给此Servlet添加POST请求处理,直接转交给Get请求处理: - -```java -@Override -protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - this.doGet(req, resp); -} -``` - -再次访问,成功得到结果,但是我们发现,浏览器只发起了一次请求,并没有再次请求新的URL,也就是说,这一次请求直接返回了请求转发后的处理结果。 - -那么,请求转发有什么好处呢?它可以携带数据! - -```java -req.setAttribute("test", "我是请求转发前的数据"); -req.getRequestDispatcher("/time").forward(req, resp); -``` - -```java -System.out.println(req.getAttribute("test")); -``` - -通过`setAttribute`方法来给当前请求添加一个附加数据,在请求转发后,我们可以直接获取到该数据。 - -重定向属于2次请求,因此无法使用这种方式来传递数据,那么,如何在重定向之间传递数据呢?我们可以使用即将要介绍的ServletContext对象。 - -最后总结,两者的区别为: - -* 请求转发是一次请求,重定向是两次请求 -* 请求转发地址栏不会发生改变, 重定向地址栏会发生改变 -* 请求转发可以共享请求参数 ,重定向之后,就获取不了共享参数了 -* 请求转发只能转发给内部的Servlet - -### 了解ServletContext对象 - -ServletContext全局唯一,它是属于整个Web应用程序的,我们可以通过`getServletContext()`来获取到此对象。 - -此对象也能设置附加值: - -```java -ServletContext context = getServletContext(); -context.setAttribute("test", "我是重定向之前的数据"); -resp.sendRedirect("time"); -``` - -```java -System.out.println(getServletContext().getAttribute("test")); -``` - -因为无论在哪里,无论什么时间,获取到的ServletContext始终是同一个对象,因此我们可以随时随地获取我们添加的属性。 - -它不仅仅可以用来进行数据传递,还可以做一些其他的事情,比如请求转发: - -```java -context.getRequestDispatcher("/time").forward(req, resp); -``` - -它还可以获取根目录下的资源文件(注意是webapp根目录下的,不是resource中的资源) - -### 初始化参数 - -初始化参数类似于初始化配置需要的一些值,比如我们的数据库连接相关信息,就可以通过初始化参数来给予Servlet,或是一些其他的配置项,也可以使用初始化参数来实现。 - -我们可以给一个Servlet添加一些初始化参数: - -```java -@WebServlet(value = "/login", initParams = { - @WebInitParam(name = "test", value = "我是一个默认的初始化参数") -}) -``` - -它也是以键值对形式保存的,我们可以直接通过Servlet的`getInitParameter`方法获取: - -```java -System.out.println(getInitParameter("test")); -``` - -但是,这里的初始化参数仅仅是针对于此Servlet,我们也可以定义全局初始化参数,只需要在web.xml编写即可: - -```xml - - lbwnb - 我是全局初始化参数 - -``` - -我们需要使用ServletContext来读取全局初始化参数: - -```java -ServletContext context = getServletContext(); -System.out.println(context.getInitParameter("lbwnb")); -``` - -有关ServletContext其他的内容,我们需要完成后面内容的学习,才能理解。 - -*** - -## Cookie - -什么是Cookie?不是曲奇,它可以在浏览器中保存一些信息,并且在下次请求时,请求头中会携带这些信息。 - -我们可以编写一个测试用例来看看: - -```java -Cookie cookie = new Cookie("test", "yyds"); -resp.addCookie(cookie); -resp.sendRedirect("time"); -``` - -```java -for (Cookie cookie : req.getCookies()) { - System.out.println(cookie.getName() + ": " + cookie.getValue()); -} -``` - -我们可以观察一下,在`HttpServletResponse`中添加Cookie之后,浏览器的响应头中会包含一个`Set-Cookie`属性,同时,在重定向之后,我们的请求头中,会携带此Cookie作为一个属性,同时,我们可以直接通过`HttpServletRequest`来快速获取有哪些Cookie信息。 - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fwww.uml.org.cn%2Fxjs%2Fimages%2F2019032226.jpg&refer=http%3A%2F%2Fwww.uml.org.cn&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1640427806&t=a452f8b27a0769ca82d2269664e71a5e) - -还有这么神奇的事情吗?那么我们来看看,一个Cookie包含哪些信息: - -* name - Cookie的名称,Cookie一旦创建,名称便不可更改 -* value - Cookie的值,如果值为Unicode字符,需要为字符编码。如果为二进制数据,则需要使用BASE64编码 -* maxAge - Cookie失效的时间,单位秒。如果为正数,则该Cookie在maxAge秒后失效。如果为负数,该Cookie为临时Cookie,关闭浏览器即失效,浏览器也不会以任何形式保存该Cookie。如果为0,表示删除该Cookie。默认为-1。 -* secure - 该Cookie是否仅被使用安全协议传输。安全协议。安全协议有HTTPS,SSL等,在网络上传输数据之前先将数据加密。默认为false。 -* path - Cookie的使用路径。如果设置为“/sessionWeb/”,则只有contextPath为“/sessionWeb”的程序可以访问该Cookie。如果设置为“/”,则本域名下contextPath都可以访问该Cookie。注意最后一个字符必须为“/”。 -* domain - 可以访问该Cookie的域名。如果设置为“.google.com”,则所有以“google.com”结尾的域名都可以访问该Cookie。注意第一个字符必须为“.”。 -* comment - 该Cookie的用处说明,浏览器显示Cookie信息的时候显示该说明。 -* version - Cookie使用的版本号。0表示遵循Netscape的Cookie规范,1表示遵循W3C的RFC 2109规范 - -我们发现,最关键的其实是`name`、`value`、`maxAge`、`domain`属性。 - -那么我们来尝试修改一下maxAge来看看失效时间: - -```java -cookie.setMaxAge(20); -``` - -设定为20秒,我们可以直接看到,响应头为我们设定了20秒的过期时间。20秒内访问都会携带此Cookie,而超过20秒,Cookie消失。 - -既然了解了Cookie的作用,我们就可以通过使用Cookie来实现记住我功能,我们可以将用户名和密码全部保存在Cookie中,如果访问我们的首页时携带了这些Cookie,那么我们就可以直接为用户进行登陆,如果登陆成功则直接跳转到首页,如果登陆失败,则清理浏览器中的Cookie。 - -那么首先,我们先在前端页面的表单中添加一个勾选框: - -```html -
- -
-``` - -接着,我们在登陆成功时进行判断,如果用户勾选了记住我,那么就讲Cookie存储到本地: - -```java -if(map.containsKey("remember-me")){ //若勾选了勾选框,那么会此表单信息 - Cookie cookie_username = new Cookie("username", username); - cookie_username.setMaxAge(30); - Cookie cookie_password = new Cookie("password", password); - cookie_password.setMaxAge(30); - resp.addCookie(cookie_username); - resp.addCookie(cookie_password); -} -``` - -然后,我们修改一下默认的请求地址,现在一律通过`http://localhost:8080/yyds/login`进行登陆,那么我们需要添加GET请求的相关处理: - -```java -@Override -protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - Cookie[] cookies = req.getCookies(); - if(cookies != null){ - String username = null; - String password = null; - for (Cookie cookie : cookies) { - if(cookie.getName().equals("username")) username = cookie.getValue(); - if(cookie.getName().equals("password")) password = cookie.getValue(); - } - if(username != null && password != null){ - //登陆校验 - try (SqlSession sqlSession = factory.openSession(true)){ - UserMapper mapper = sqlSession.getMapper(UserMapper.class); - User user = mapper.getUser(username, password); - if(user != null){ - resp.sendRedirect("time"); - return; //直接返回 - } - } - } - } - req.getRequestDispatcher("/").forward(req, resp); //正常情况还是转发给默认的Servlet帮我们返回静态页面 -} -``` - -现在,30秒内都不需要登陆,访问登陆页面后,会直接跳转到time页面。 - -现在已经离我们理想的页面越来越接近了,但是仍然有一个问题,就是我们的首页,无论是否登陆,所有人都可以访问,那么,如何才可以实现只有登陆之后才能访问呢?这就需要用到Session了。 - -*** - -## Session - -由于HTTP是无连接的,那么如何能够辨别当前的请求是来自哪个用户发起的呢?Session就是用来处理这种问题的,每个用户的会话都会有一个自己的Session对象,来自同一个浏览器的所有请求,就属于同一个会话。 - -但是HTTP协议是无连接的呀,那Session是如何做到辨别是否来自同一个浏览器呢?Session实际上是基于Cookie实现的,前面我们了解了Cookie,我们知道,服务端可以将Cookie保存到浏览器,当浏览器下次访问时,就会附带这些Cookie信息。 - -Session也利用了这一点,它会给浏览器设定一个叫做`JSESSIONID`的Cookie,值是一个随机的排列组合,而此Cookie就对应了你属于哪一个对话,只要我们的浏览器携带此Cookie访问服务器,服务器就会通过Cookie的值进行辨别,得到对应的Session对象,因此,这样就可以追踪到底是哪一个浏览器在访问服务器。 - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fwww.xaecong.com%2Fuploadfile%2F2018-5%2F20180511113613649.gif&refer=http%3A%2F%2Fwww.xaecong.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1640433362&t=bbfd240d9a7ec60468840f01b097d2a2) - -那么现在,我们在用户登录成功之后,将用户对象添加到Session中,只要是此用户发起的请求,我们都可以从`HttpSession`中读取到存储在会话中的数据: - -```java -HttpSession session = req.getSession(); -session.setAttribute("user", user); -``` - -同时,如果用户没有登录就去访问首页,那么我们将发送一个重定向请求,告诉用户,需要先进行登录才可以访问: - -```java -HttpSession session = req.getSession(); -User user = (User) session.getAttribute("user"); -if(user == null) { - resp.sendRedirect("login"); - return; -} -``` - -在访问的过程中,注意观察Cookie变化。 - -Session并不是永远都存在的,它有着自己的过期时间,默认时间为30分钟,若超过此时间,Session将丢失,我们可以在配置文件中修改过期时间: - -```xml - - 1 - -``` - -我们也可以在代码中使用`invalidate`方法来使Session立即失效: - -```java -session.invalidate(); -``` - -现在,通过Session,我们就可以更好地控制用户对于资源的访问,只有完成登陆的用户才有资格访问首页。 - -## Filter - -有了Session之后,我们就可以很好地控制用户的登陆验证了,只有授权的用户,才可以访问一些页面,但是我们需要一个一个去进行配置,还是太过复杂,能否一次性地过滤掉没有登录验证的用户呢? - -过滤器相当于在所有访问前加了一堵墙,来自浏览器的所有访问请求都会首先经过过滤器,只有过滤器允许通过的请求,才可以顺利地到达对应的Servlet,而过滤器不允许的通过的请求,我们可以自由地进行控制是否进行重定向或是请求转发。并且过滤器可以添加很多个,就相当于添加了很多堵墙,我们的请求只有穿过层层阻碍,才能与Servlet相拥,像极了爱情。 - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimages.cnitblog.com%2Fblog%2F150046%2F201501%2F072114593437292.png&refer=http%3A%2F%2Fimages.cnitblog.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1640479081&t=a1259950e28398b095ea7ce30c022904) - -添加一个过滤器非常简单,只需要实现Filter接口,并添加`@WebFilter`注解即可: - -```java -@WebFilter("/*") //路径的匹配规则和Servlet一致,这里表示匹配所有请求 -public class TestFilter implements Filter { - @Override - public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { - - } -} -``` - -这样我们就成功地添加了一个过滤器,那么添加一句打印语句看看,是否所有的请求都会经过此过滤器: - -```java -HttpServletRequest request = (HttpServletRequest) servletRequest; -System.out.println(request.getRequestURL()); -``` - -我们发现,现在我们发起的所有请求,一律需要经过此过滤器,并且所有的请求都没有任何的响应内容。 - -那么如何让请求可以顺利地到达对应的Servlet,也就是说怎么让这个请求顺利通过呢?我们只需要在最后添加一句: - -```java -filterChain.doFilter(servletRequest, servletResponse); -``` - -那么这行代码是什么意思呢? - -由于我们整个应用程序可能存在多个过滤器,那么这行代码的意思实际上是将此请求继续传递给下一个过滤器,当没有下一个过滤器时,才会到达对应的Servlet进行处理,我们可以再来创建一个过滤器看看效果: - -```java -@WebFilter("/*") -public class TestFilter2 implements Filter { - @Override - public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { - System.out.println("我是2号过滤器"); - filterChain.doFilter(servletRequest, servletResponse); - } -} -``` - -由于过滤器的过滤顺序是按照类名的自然排序进行的,因此我们将第一个过滤器命名进行调整。 - -我们发现,在经过第一个过滤器之后,会继续前往第二个过滤器,只有两个过滤器全部经过之后,才会到达我们的Servlet中。 - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimages.cnitblog.com%2Fblog%2F34303%2F201212%2F30153033-d9e09a9c8dfe403fb9f6303052ba4b6c.png&refer=http%3A%2F%2Fimages.cnitblog.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1640479150&t=d14eb2b4c2a3d6e987fc9cf6680a326f) - -实际上,当`doFilter`方法调用时,就会一直向下直到Servlet,在Servlet处理完成之后,又依次返回到最前面的Filter,类似于递归的结构,我们添加几个输出语句来判断一下: - -```java -@Override -public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { - System.out.println("我是2号过滤器"); - filterChain.doFilter(servletRequest, servletResponse); - System.out.println("我是2号过滤器,处理后"); -} -``` - -```java -@Override -public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { - System.out.println("我是1号过滤器"); - filterChain.doFilter(servletRequest, servletResponse); - System.out.println("我是1号过滤器,处理后"); -} -``` - -最后验证我们的结论。 - -同Servlet一样,Filter也有对应的HttpFilter专用类,它针对HTTP请求进行了专门处理,因此我们可以直接使用HttpFilter来编写: - -```java -public abstract class HttpFilter extends GenericFilter { - private static final long serialVersionUID = 7478463438252262094L; - - public HttpFilter() { - } - - public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { - if (req instanceof HttpServletRequest && res instanceof HttpServletResponse) { - this.doFilter((HttpServletRequest)req, (HttpServletResponse)res, chain); - } else { - throw new ServletException("non-HTTP request or response"); - } - } - - protected void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException { - chain.doFilter(req, res); - } -} -``` - -那么现在,我们就可以给我们的应用程序添加一个过滤器,用户在未登录情况下,只允许静态资源和登陆页面请求通过,登陆之后畅行无阻: - -```java -@WebFilter("/*") -public class MainFilter extends HttpFilter { - @Override - protected void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException { - String url = req.getRequestURL().toString(); - //判断是否为静态资源 - if(!url.endsWith(".js") && !url.endsWith(".css") && !url.endsWith(".png")){ - HttpSession session = req.getSession(); - User user = (User) session.getAttribute("user"); - //判断是否未登陆 - if(user == null && !url.endsWith("login")){ - res.sendRedirect("login"); - return; - } - } - //交给过滤链处理 - chain.doFilter(req, res); - } -} -``` - -现在,我们的页面已经基本完善为我们想要的样子了。 - -当然,可能跟着教程编写的项目比较乱,大家可以自己花费一点时间来重新编写一个Web应用程序,加深对之前讲解知识的理解。我们也会在之后安排一个编程实战进行深化练习。 - -*** - -## Listener - -监听器并不是我们学习的重点内容,那么什么是监听器呢? - -如果我们希望,在应用程序加载的时候,或是Session创建的时候,亦或是在Request对象创建的时候进行一些操作,那么这个时候,我们就可以使用监听器来实现。 - -![img](https://img-blog.csdn.net/20180825212011379?watermark/2/text/aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzE1MjA0MTc5/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70) - -默认为我们提供了很多类型的监听器,我们这里就演示一下监听Session的创建即可: - -```java -@WebListener -public class TestListener implements HttpSessionListener { - @Override - public void sessionCreated(HttpSessionEvent se) { - System.out.println("有一个Session被创建了"); - } -} -``` - -有关监听器相关内容,了解即可。 - -*** - -## 了解JSP页面与加载规则 - -前面我们已经完成了整个Web应用程序生命周期中所有内容的学习,我们已经完全了解,如何编写一个Web应用程序,并放在Tomcat上部署运行,以及如何控制浏览器发来的请求,通过Session+Filter实现用户登陆验证,通过Cookie实现自动登陆等操作。到目前为止,我们已经具备编写一个完整Web网站的能力。 - -在之前的教程中,我们的前端静态页面并没有与后端相结合,我们前端页面所需的数据全部需要单独向后端发起请求获取,并动态进行内容填充,这是一种典型的前后端分离写法,前端只负责要数据和显示数据,后端只负责处理数据和提供数据,这也是现在更流行的一种写法,让前端开发者和后端开发者各尽其责,更加专一,这才是我们所希望的开发模式。 - -JSP并不是我们需要重点学习的内容,因为它已经过时了,使用JSP会导致前后端严重耦合,因此这里只做了解即可。 - -JSP其实就是一种模板引擎,那么何谓模板引擎呢?顾名思义,它就是一个模板,而模板需要我们填入数据,才可以变成一个页面,也就是说,我们可以直接在前端页面中直接填写数据,填写后生成一个最终的HTML页面返回给前端。 - -首先我们来创建一个新的项目,项目创建成功后,删除Java目录下的内容,只留下默认创建的jsp文件,我们发现,在webapp目录中,存在一个`index.jsp`文件,现在我们直接运行项目,会直接访问这个JSP页面。 - -```jsp -<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> - - - - JSP - Hello World - - -

<%= "Hello World!" %> -

-
-Hello Servlet - - -``` - -但是我们并没有编写对应的Servlet来解析啊,那么为什么这个JSP页面会被加载呢? - -实际上,我们一开始提到的两个Tomcat默认的Servlet中,一个是用于请求静态资源,还有一个就是用于处理jsp的: - -```xml - - - jsp - *.jsp - *.jspx - -``` - -那么,JSP和普通HTML页面有什么区别呢,我们发现它的语法和普通HTML页面几乎一致,我们可以直接在JSP中编写Java代码,并在页面加载的时候执行,我们随便找个地方插入: - -```jsp -<% - System.out.println("JSP页面被加载"); -%> -``` - -我们发现,请求一次页面,页面就会加载一次,并执行我们填写的Java代码。也就是说,我们可以直接在此页面中执行Java代码来填充我们的数据,这样我们的页面就变成了一个动态页面,使用`<%= %>`来填写一个值: - -```jsp -

<%= new Date() %>

-``` - -现在访问我们的网站,每次都会创建一个新的Date对象,因此每次访问获取的时间都不一样,我们的网站已经算是一个动态的网站的了。 - -虽然这样在一定程度上上为我们提供了便利,但是这样的写法相当于整个页面既要编写前端代码,也要编写后端代码,随着项目的扩大,整个页面会显得难以阅读,并且现在都是前后端开发人员职责非常明确的,如果要编写JSP页面,那就必须要招一个既会前端也会后端的程序员,这样显然会导致不必要的开销。 - -那么我们来研究一下,为什么JSP页面能够在加载的时候执行Java代码呢? - -首先我们将此项目打包,并在Tomcat服务端中运行,生成了一个文件夹并且可以正常访问。 - -我们现在看到`work`目录,我们发现这个里面多了一个`index_jsp.java`和`index_jsp.class`,那么这些东西是干嘛的呢,我们来反编译一下就啥都知道了: - -```java -public final class index_jsp extends org.apache.jasper.runtime.HttpJspBase //继承自HttpServlet - implements org.apache.jasper.runtime.JspSourceDependent, - org.apache.jasper.runtime.JspSourceImports { - - ... - - public void _jspService(final jakarta.servlet.http.HttpServletRequest request, final jakarta.servlet.http.HttpServletResponse response) - throws java.io.IOException, jakarta.servlet.ServletException { - - if (!jakarta.servlet.DispatcherType.ERROR.equals(request.getDispatcherType())) { - final java.lang.String _jspx_method = request.getMethod(); - if ("OPTIONS".equals(_jspx_method)) { - response.setHeader("Allow","GET, HEAD, POST, OPTIONS"); - return; - } - if (!"GET".equals(_jspx_method) && !"POST".equals(_jspx_method) && !"HEAD".equals(_jspx_method)) { - response.setHeader("Allow","GET, HEAD, POST, OPTIONS"); - response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED, "JSP 只允许 GET、POST 或 HEAD。Jasper 还允许 OPTIONS"); - return; - } - } - - final jakarta.servlet.jsp.PageContext pageContext; - jakarta.servlet.http.HttpSession session = null; - final jakarta.servlet.ServletContext application; - final jakarta.servlet.ServletConfig config; - jakarta.servlet.jsp.JspWriter out = null; - final java.lang.Object page = this; - jakarta.servlet.jsp.JspWriter _jspx_out = null; - jakarta.servlet.jsp.PageContext _jspx_page_context = null; - - - try { - response.setContentType("text/html; charset=UTF-8"); - pageContext = _jspxFactory.getPageContext(this, request, response, - null, true, 8192, true); - _jspx_page_context = pageContext; - application = pageContext.getServletContext(); - config = pageContext.getServletConfig(); - session = pageContext.getSession(); - out = pageContext.getOut(); - _jspx_out = out; - - out.write("\n"); - out.write("\n"); - out.write("\n"); - out.write("\n"); - out.write("\n"); - out.write(" JSP - Hello World\n"); - out.write("\n"); - out.write("\n"); - out.write("

"); - out.print( new Date() ); - out.write("

\n"); - - System.out.println("JSP页面被加载"); - - out.write("\n"); - out.write("
\n"); - out.write("Hello Servlet\n"); - out.write("\n"); - out.write(""); - } catch (java.lang.Throwable t) { - if (!(t instanceof jakarta.servlet.jsp.SkipPageException)){ - out = _jspx_out; - if (out != null && out.getBufferSize() != 0) - try { - if (response.isCommitted()) { - out.flush(); - } else { - out.clearBuffer(); - } - } catch (java.io.IOException e) {} - if (_jspx_page_context != null) _jspx_page_context.handlePageException(t); - else throw new ServletException(t); - } - } finally { - _jspxFactory.releasePageContext(_jspx_page_context); - } - } -} -``` - -我们发现,它是继承自`HttpJspBase`类,我们可以反编译一下jasper.jar(它在tomcat的lib目录中)来看看: - -```java -package org.apache.jasper.runtime; - -import jakarta.servlet.ServletConfig; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServlet; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.jsp.HttpJspPage; -import java.io.IOException; -import org.apache.jasper.compiler.Localizer; - -public abstract class HttpJspBase extends HttpServlet implements HttpJspPage { - private static final long serialVersionUID = 1L; - - protected HttpJspBase() { - } - - public final void init(ServletConfig config) throws ServletException { - super.init(config); - this.jspInit(); - this._jspInit(); - } - - public String getServletInfo() { - return Localizer.getMessage("jsp.engine.info", new Object[]{"3.0"}); - } - - public final void destroy() { - this.jspDestroy(); - this._jspDestroy(); - } - - public final void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { - this._jspService(request, response); - } - - public void jspInit() { - } - - public void _jspInit() { - } - - public void jspDestroy() { - } - - protected void _jspDestroy() { - } - - public abstract void _jspService(HttpServletRequest var1, HttpServletResponse var2) throws ServletException, IOException; -} -``` - -实际上,Tomcat在加载JSP页面时,会将其动态转换为一个java类并编译为class进行加载,而生成的Java类,正是一个Servlet的子类,而页面的内容全部被编译为输出字符串,这便是JSP的加载原理,因此,JSP本质上依然是一个Servlet! - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg.debugrun.com%2Fpic%2F2017%2F10%2F8%2F017e6d66d6d9589dfc7377a052ca8047.png&refer=http%3A%2F%2Fimg.debugrun.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1640487718&t=2656b55a2eb461b4a90afb1076aeb355) - -如果同学们感兴趣的话,可以查阅一下其他相关的教程,本教程不再讲解此技术。 - -*** - -## 使用Thymeleaf模板引擎 - -虽然JSP为我们带来了便捷,但是其缺点也是显而易见的,那么有没有一种既能实现模板,又能兼顾前后端分离的模板引擎呢? - -**Thymeleaf**(百里香叶)是一个适用于Web和独立环境的现代化服务器端Java模板引擎,官方文档:https://www.thymeleaf.org/documentation.html。 - -那么它和JSP相比,好在哪里呢,我们来看官网给出的例子: - -```html - - - - - - - - - - - - - -
NamePrice
Oranges0.99
-``` - -我们可以在前端页面中填写占位符,而这些占位符的实际值则由后端进行提供,这样,我们就不用再像JSP那样前后端都写在一起了。 - -那么我们来创建一个例子感受一下,首先还是新建一个项目,注意,在创建时,勾选Thymeleaf依赖。 - -首先编写一个前端页面,名称为`test.html`,注意,是放在resource目录下,在html标签内部添加`xmlns:th="http://www.thymeleaf.org"`引入Thymeleaf定义的标签属性: - -```html - - - - - Title - - -
- - -``` - -接着我们编写一个Servlet作为默认页面: - -```java -@WebServlet("/index") -public class HelloServlet extends HttpServlet { - - TemplateEngine engine; - @Override - public void init() throws ServletException { - engine = new TemplateEngine(); - ClassLoaderTemplateResolver r = new ClassLoaderTemplateResolver(); - engine.setTemplateResolver(r); - } - - @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - Context context = new Context(); - context.setVariable("title", "我是标题"); - engine.process("test.html", context, resp.getWriter()); - } -} -``` - -我们发现,浏览器得到的页面,就是已经经过模板引擎解析好的页面,而我们的代码依然是后端处理数据,前端展示数据,因此使用Thymeleaf就能够使得当前Web应用程序的前后端划分更加清晰。 - -虽然Thymeleaf在一定程度上分离了前后端,但是其依然是在后台渲染HTML页面并发送给前端,并不是真正意义上的前后端分离。 - -### Thymeleaf语法基础 - -那么,如何使用Thymeleaf呢? - -首先我们看看后端部分,我们需要通过`TemplateEngine`对象来将模板文件渲染为最终的HTML页面: - -```java -TemplateEngine engine; -@Override -public void init() throws ServletException { - engine = new TemplateEngine(); - //设定模板解析器决定了从哪里获取模板文件,这里直接使用ClassLoaderTemplateResolver表示加载内部资源文件 - ClassLoaderTemplateResolver r = new ClassLoaderTemplateResolver(); - engine.setTemplateResolver(r); -} -``` - -由于此对象只需要创建一次,之后就可以一直使用了。接着我们来看如何使用模板引擎进行解析: - -```java -@Override -protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - //创建上下文,上下文中包含了所有需要替换到模板中的内容 - Context context = new Context(); - context.setVariable("title", "

我是标题

"); - //通过此方法就可以直接解析模板并返回响应 - engine.process("test.html", context, resp.getWriter()); -} -``` - -操作非常简单,只需要简单几步配置就可以实现模板的解析。接下来我们就可以在前端页面中通过上下文提供的内容,来将Java代码中的数据解析到前端页面。 - -接着我们来了解Thymeleaf如何为普通的标签添加内容,比如我们示例中编写的: - -```html -
-``` - -我们使用了`th:text`来为当前标签指定内部文本,注意任何内容都会变成普通文本,即使传入了一个HTML代码,如果我希望向内部添加一个HTML文本呢?我们可以使用`th:utext`属性: - -```html -
-``` - -并且,传入的title属性,不仅仅只是一个字符串的值,而是一个字符串的引用,我们可以直接通过此引用调用相关的方法: - -```html -
-``` - -这样看来,Thymeleaf既能保持JSP为我们带来的便捷,也能兼顾前后端代码的界限划分。 - -除了替换文本,它还支持替换一个元素的任意属性,我们发现,`th:`能够拼接几乎所有的属性,一旦使用`th:属性名称`,那么属性的值就可以通过后端提供了,比如我们现在想替换一个图片的链接: - -```java -@Override -protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - Context context = new Context(); - context.setVariable("url", "http://n.sinaimg.cn/sinakd20121/600/w1920h1080/20210727/a700-adf8480ff24057e04527bdfea789e788.jpg"); - context.setVariable("alt", "图片就是加载不出来啊"); - engine.process("test.html", context, resp.getWriter()); -} -``` - -```html - - - - - Title - - - - - -``` - -现在访问我们的页面,就可以看到替换后的结果了。 - -Thymeleaf还可以进行一些算术运算,几乎Java中的运算它都可以支持: - -```html -
-``` - -同样的,它还支持三元运算: - -```html -
-``` - -多个属性也可以通过`+`进行拼接,就像Java中的字符串拼接一样,这里要注意一下,字符串不能直接写,要添加单引号: - -```html -
-``` - -### Thymeleaf流程控制语法 - -除了一些基本的操作,我们还可以使用Thymeleaf来处理流程控制语句,当然,不是直接编写Java代码的形式,而是添加一个属性即可。 - -首先我们来看if判断语句,如果if条件满足,则此标签留下,若if条件不满足,则此标签自动被移除: - -```java -@Override -protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - Context context = new Context(); - context.setVariable("eval", true); - engine.process("test.html", context, resp.getWriter()); -} -``` - -```html -
我是判断条件标签
-``` - -`th:if`会根据其中传入的值或是条件表达式的结果进行判断,只有满足的情况下,才会显示此标签,具体的判断规则如下: - -- 如果值不是空的: - - 如果值是布尔值并且为`true`。 - - 如果值是一个数字,并且是非零 - - 如果值是一个字符,并且是非零 - - 如果值是一个字符串,而不是“错误”、“关闭”或“否” - - 如果值不是布尔值、数字、字符或字符串。 -- 如果值为空,th:if将计算为false - -`th:if`还有一个相反的属性`th:unless`,效果完全相反,这里就不演示了。 - -我们接着来看多分支条件判断,我们可以使用`th:switch`属性来实现: - -```html -
-
我是1
-
我是2
-
我是3
-
-``` - -只不过没有default属性,但是我们可以使用`th:case="*"`来代替: - -```html -
我是Default
-``` - -最后我们再来看看,它如何实现遍历,假如我们有一个存放书籍信息的List需要显示,那么如何快速生成一个列表呢?我们可以使用`th:each`来进行遍历操作: - -```java -@Override -protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - Context context = new Context(); - context.setVariable("list", Arrays.asList("伞兵一号的故事", "倒一杯卡布奇诺", "玩游戏要啸着玩", "十七张牌前的电脑屏幕")); - engine.process("test.html", context, resp.getWriter()); -} -``` - -```html -
    -
  • -
-``` - -`th:each`中需要填写 "单个元素名称 : ${列表}",这样,所有的列表项都可以使用遍历的单个元素,只要使用了`th:each`,都会被循环添加。因此最后生成的结果为: - -```html -
    -
  • 《伞兵一号的故事》
  • -
  • 《倒一杯卡布奇诺》
  • -
  • 《玩游戏要啸着玩》
  • -
  • 《十七张牌前的电脑屏幕》
  • -
-``` - -我们还可以获取当前循环的迭代状态,只需要在最后添加`iterStat`即可,从中可以获取很多信息,比如当前的顺序: - -```html -
    -
  • -
-``` - -状态变量在`th:each`属性中定义,并包含以下数据: - -- 当前*迭代索引*,以0开头。这是`index`属性。 -- 当前*迭代索引*,以1开头。这是`count`属性。 -- 迭代变量中的元素总量。这是`size`属性。 -- 每个迭代的*迭代变量*。这是`current`属性。 -- 当前迭代是偶数还是奇数。这些是`even/odd`布尔属性。 -- 当前迭代是否是第一个迭代。这是`first`布尔属性。 -- 当前迭代是否是最后一个迭代。这是`last`布尔属性。 - -通过了解了流程控制语法,现在我们就可以很轻松地使用Thymeleaf来快速替换页面中的内容了。 - -### Thymeleaf模板布局 - -在某些网页中,我们会发现,整个网站的页面,除了中间部分的内容会随着我们的页面跳转而变化外,有些部分是一直保持一个状态的,比如打开小破站,我们翻动评论或是切换视频分P的时候,变化的仅仅是对应区域的内容,实际上,其他地方的内容会无论内部页面如何跳转,都不会改变。 - -Thymeleaf就可以轻松实现这样的操作,我们只需要将不会改变的地方设定为模板布局,并在不同的页面中插入这些模板布局,就无需每个页面都去编写同样的内容了。现在我们来创建两个页面: - -```html - - - - - Title - - -
-
-

我是标题内容,每个页面都有

-
-
-
-
-
    -
  • -
-
- - -``` - -```html - - - - - Title - - -
-
-

我是标题内容,每个页面都有

-
-
-
-
-
这个页面的样子是这样的
-
- - -``` - -接着将模板引擎写成工具类的形式: - -```java -public class ThymeleafUtil { - - private static final TemplateEngine engine; - static { - engine = new TemplateEngine(); - ClassLoaderTemplateResolver r = new ClassLoaderTemplateResolver(); - engine.setTemplateResolver(r); - } - - public static TemplateEngine getEngine() { - return engine; - } -} -``` - -```java -@WebServlet("/index2") -public class HelloServlet2 extends HttpServlet { - - @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - Context context = new Context(); - ThymeleafUtil.getEngine().process("test2.html", context, resp.getWriter()); - } -} -``` - -现在就有两个Servlet分别对应两个页面了,但是这两个页面实际上是存在重复内容的,我们要做的就是将这些重复内容提取出来。 - -我们单独编写一个`head.html`来存放重复部分: - -```html - - - -
-
-

我是标题内容,每个页面都有

-
-
-
- - -``` - -现在,我们就可以直接将页面中的内容快速替换: - -```html -
-
-
    -
  • -
-
-``` - -我们可以使用`th:insert`和`th:replace`和`th:include`这三种方法来进行页面内容替换,那么`th:insert`和`th:replace`(和`th:include`,自3.0年以来不推荐)有什么区别? - -- `th:insert`最简单:它只会插入指定的片段作为标签的主体。 -- `th:replace`实际上将标签直接*替换*为指定的片段。 -- `th:include`和`th:insert`相似,但它没有插入片段,而是只插入此片段*的内容*。 - -你以为这样就完了吗?它还支持参数传递,比如我们现在希望插入二级标题,并且由我们的子页面决定: - -```html -
-
-

我是标题内容,每个页面都有

-

我是二级标题

-
-
-
-``` - -稍加修改,就像JS那样添加一个参数名称: - -```html -
-
-

我是标题内容,每个页面都有

-

-
-
-
-``` - -现在直接在替换位置添加一个参数即可: - -```html -
-
-
    -
  • -
-
-``` - -这样,不同的页面还有着各自的二级标题。 - -*** - -## 探讨Tomcat类加载机制 - -有关JavaWeb的内容,我们就聊到这里,在最后,我们还是来看一下Tomcat到底是如何加载和运行我们的Web应用程序的。 - -Tomcat服务器既然要同时运行多个Web应用程序,那么就必须要实现不同应用程序之间的隔离,也就是说,Tomcat需要分别去加载不同应用程序的类以及依赖,还必须保证应用程序之间的类无法相互访问,而传统的类加载机制无法做到这一点,同时每个应用程序都有自己的依赖,如果两个应用程序使用了同一个版本的同一个依赖,那么还有必要去重新加载吗,带着诸多问题,Tomcat服务器编写了一套自己的类加载机制。 - -![img](https://images2018.cnblogs.com/blog/137084/201805/137084-20180526104342525-959933190.png) - -首先我们要知道,Tomcat本身也是一个Java程序,它要做的是去动态加载我们编写的Web应用程序中的类,而要解决以上提到的一些问题,就出现了几个新的类加载器,我们来看看各个加载器的不同之处: - -- Common ClassLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Web应用程序访问。 -- Catalina ClassLoader:Tomcat容器私有的类加载器,加载路径中的class对于Web应用程序不可见。 -- Shared ClassLoader:各个Web应用程序共享的类加载器,加载路径中的class对于所有Web应用程序可见,但是对于Tomcat容器不可见。 -- Webapp ClassLoader:各个Web应用程序私有的类加载器,加载路径中的class只对当前Web应用程序可见,每个Web应用程序都有一个自己的类加载器,此加载器可能存在多个实例。 -- JasperLoader:JSP类加载器,每个JSP文件都有一个自己的类加载器,也就是说,此加载器可能会存在多个实例。 - -通过这样进行划分,就很好地解决了我们上面所提到的问题,但是我们发现,这样的类加载机制,破坏了JDK的`双亲委派机制`(在JavaSE阶段讲解过),比如Webapp ClassLoader,它只加载自己的class文件,它没有将类交给父类加载器进行加载,也就是说,我们可以随意创建和JDK同包同名的类,岂不是就出问题了? - -难道Tomcat的开发团队没有考虑到这个问题吗? - -![img](https://images0.cnblogs.com/blog2015/449064/201506/141304597074685.jpg) - -实际上,WebAppClassLoader的加载机制是这样的:WebAppClassLoader 加载类的时候,绕开了 AppClassLoader,直接先使用 ExtClassLoader 来加载类。这样的话,如果定义了同包同名的类,就不会被加载,而如果是自己定义 的类,由于该类并不是JDK内部或是扩展类,所有不会被加载,而是再次回到WebAppClassLoader进行加载,如果还失败,再使用AppClassloader进行加载。 - -*** - -## 实战:编写图书管理系统 - -图书管理系统需要再次迎来升级,现在,我们可以直接访问网站来操作图书,这里我们给大家提供一个前端模板直接编写,省去编写前端的时间。 - -本次实战使用到的框架:Servlet+Mybatis+Thymeleaf - -注意在编写的时候,为了使得整体的代码简洁高效,我们严格遵守三层架构模式: - -![img](https://www.runoob.com/wp-content/uploads/2018/08/1535337833-4838-1359192395-1143.png) - -就是说,表示层只做UI,包括接受请求和相应,给模板添加上下文,以及进行页面的解析,最后响应给浏览器;业务逻辑层才是用于进行数据处理的地方,表示层需要向逻辑层索要数据,才能将数据添加到模板的上下文中;数据访问层一般就是连接数据库,包括增删改查等基本的数据库操作,业务逻辑层如果需要从数据库取数据,就需要向数据访问层请求数据。 - -当然,贯穿三大层次的当属实体类了,我们还需要创建对应的实体类进行数据的封装,以便于在三层架构中进行数据传递。 - -接下来,明确我们要实现的功能,也就是项目需求: - -* 图书管理员的登陆和退出(只有登陆之后才能进入管理页面) -* 图书的列表浏览(包括书籍是否被借出的状态也要进行显示)以及图书的添加和删除 -* 学生的列表浏览 -* 查看所有的借阅列表,添加借阅信息 - -*** - -## 结束语 - -首先祝贺各位顺利完成了JavaWeb相关知识的学习。 - -本教程创作的动力离不开各位观众姥爷们的支持,我们也会在后面为大家录制更多的Java技术栈教程,如果您喜欢本系列视频的话,直接用三连狠狠的砸向UP主吧! - -虽然我们现在已经学会了如何去编写一个网站,但是实际上,这样的开发模式已经过时(不过拿去当毕设当期末设计直接无敌好吧),我们还需要继续深入了解更加现代化的开发模式,这样我们才有机会参与到企业的项目开发当中。 - -希望在后续的视频中,还能看到各位的身影,完结撒花! \ No newline at end of file diff --git a/青空笔记/JavaWeb笔记/JavaWeb笔记(四).md b/青空笔记/JavaWeb笔记/JavaWeb笔记(四).md deleted file mode 100644 index a52d858..0000000 --- a/青空笔记/JavaWeb笔记/JavaWeb笔记(四).md +++ /dev/null @@ -1,1231 +0,0 @@ -# 前端基础 - -**提醒:**还没有申请到IDEA专业版本授权的同学要抓紧了,很快就需要用到。 - -经过前面基础内容的学习,现在我们就可以正式地进入Web开发的学习当中啦~ - -本章节会讲解前端基础内容(如果已经学习过,可以直接跳到下一个大章节了)那么什么是前端,什么又是后端呢? - -* 前端:我们网站的页面,包括网站的样式、图片、视频等一切用户可见的内容都是前端的内容。 -* 后端:处理网站的所有数据来源,比如我们之前从数据库中查询数据,而我们查询的数据经过处理最终会被展示到前端,而用于处理前端数据的工作就是由后端来完成的。 - -相当于,前端仅仅是一层皮,它直接决定了整个网站的美观程度,我们可以自由地编排页面的布局,甚至可以编写好看的特效;而灵魂则是后端,如何处理用户的交互、如何处理数据查询是后端的职责所在,我们前面学习的都是后端内容,而Java也是一门专注于后端开发的语言。 - -对于前端开发我们需要学习一些新的内容,只有了解了它们,我们才能编写出美观的页面。 - -本教程并不会过多地去讲解前端知识,我们只会提及一些必要的内容,我们主要学习的是JavaWeb,更倾向于后端开发,学习前端的目的只是为了让同学们了解前后端的交互方式,在进行后端开发时思路能够更加清晰,有关前端的完整内容学习,可以浏览其他前端知识教程。 - -我们在最开始讲解网络编程时,提到了浏览器访问服务器,实际上浏览器访问服务器就是一种B/S结构,而我们使用Java代码编写的客户端连接服务器就是一种C/S结构。 - -Web开发还要从HTML开始讲起,这个语言非常简单,很好学习,看完视频如果你觉得前端简单自己更喜欢一些,建议马上转前端吧,还来得及,工资还比后端高,不像后端那么枯燥乏味。 - -## HTML页面 - -我们前面学习了XML语言,它是一种标记语言,我们需要以成对标签的格式进行填写,但是它是专用于保存数据,而不是展示数据,而HTML恰恰相反,它专用于展示数据,由于我们前面已经学习过XML语言了,HTML语言和XML很相似,所以我们学习起来会很快。 - -### 第一个HTML页面 - -我们前面知道,通过浏览器可以直接浏览XML文件,而浏览器一般是用于浏览HTML文件的,以HTML语言编写的内容,会被浏览器识别为一个页面,并根据我们编写的内容,将对应的组件添加到浏览器窗口中。 - -我们一般使用Chrome、Safari、Microsoft Edge等浏览器进行测试,IE浏览器已经彻底淘汰了! - -比如我们可以创建一个Html文件来看看浏览器会如何识别,使用IDEA也能编写HTML页面,我们在IDEA中新建一个`Web模块`,进入之后我们发现,项目中没有任何内容,我们右键新建一个HTML文件,选择HTML5文件,并命名为index,创建后出现: - -```html - - - - - Title - - - - - -``` - -我们发现,它和XML基本长得一样,并且还自带了一些标签,那么现在我们通过浏览器来浏览这个HTML文件(这里推荐使用内置预览,不然还得来回切换窗口) - -我们发现现在什么东西都没有,但是在浏览器的标签位置显示了网页的名称为`Title`,并且显示了一个IDEA的图标作为网页图标。 - -现在我们稍微进行一些修改: - -```html - - - - - lbw的直播间 - - - 现在全体起立 - - -``` - -再次打开浏览器,我们发现页面中出现了我们输入的文本内容,并且标题也改为了我们自定义的标题。 - -我们可以在设置->工具->Web浏览器和预览中将重新加载页面规则改为`变更时`,这样我们使用内置浏览器或是外部浏览器,可以自动更新我们编写的内容。 - -我们还可以在页面中添加一个图片,随便将一张图片放到html文件的同级目录下,命名为`image.xxx`,其中xxx是后缀名称,不要修改,我们在body节点中添加以下内容: - -```html -剑光如我,斩尽牛杂 - -``` - -我们发现,我们的页面中居然能够显示我们添加的图片内容。因此,我们只需要编写对应的标签,浏览器就能够自动识别为对应的组件,并将其展示到我们的浏览器窗口中。 - -我们再来看看插入一个B站的视频,很简单,只需要到对应的视频下方,找到分享,我们看到有一个嵌入代码: - -```html - -``` - -每一个页面都是通过这些标签来编写的,几乎所有的网站都是使用HTML编写页面。 - -### HTML语法规范 - -一个HTML文件中一般分为两个部分: - -* 头部:一般包含页面的标题、页面的图标、还有页面的一些设置,也可以在这里导入css、js等内容。 -* 主体:整个页面所有需要显示的内容全部在主体编写。 - -我们首先来看头部,我们之前使用的HTML文件中头部包含了这些内容: - -```html - -lbw的直播间 -``` - -首先`meta`标签用于定义页面的一些元信息,这里使用它来定义了一个字符集(编码格式),一般是UTF-8,下面的`title`标签就是页面的标题,会显示在浏览器的上方。我们现在来给页面设置一个图标,图标一般可以在字节跳动的IconPark网站找到:https://iconpark.oceanengine.com/home,选择一个自己喜欢的图标下载即可。 - -将图标放入到项目目录中,并命名为icon.png,在HTML头部添加以下内容: - -```html - -``` - -`link`标签用于关联当前HTML页面与其他资源的关系,关系通过`rel`属性指定,这里使用的是icon表示这个文件是当前页面图标。 - -现在访问此页面,我们发现页面的图标已经变成我们指定的图标样式了。 - -现在我们再来看主体,我们可以在主体内部编写该页面要展示的所有内容,比如我们之前就用到了img标签来展示一个图片,其中每一个标签都称为一个元素: - -```html -当图片加载失败时,显示的文本 -``` - -我们发现,这个标签只存在一个,并没有成对出现,HTML中有些标签是单标签,也就是说只有这一个,还有一些标签是双标签,必须成对出现,HTML中,也不允许交叉嵌套,但是出现交叉嵌套时,浏览器并不会提示错误,而是仍旧尝试去解析这些内容,甚至会帮助我们进行一定程度的修复,比如: - -```html - - - -``` - -很明显上面的代码已经出现交叉嵌套的情况了,但是依然能够在浏览器中正确地显示。 - -在主体中,我们一般使用div标签来分割页面: - -```html - -
我是第一块
-
我是第二块
- -``` - -通过使用`div`标签,我们将整个页面按行划分,而高度就是内部元素的高度,那么如果只希望按元素划分,也就是说元素占多大就划分多大的空间,那么我们就可以使用`span`标签来划分: - -```html - -
- 我是第一块第一个部分 - 我是第一块第二个部分 -
-
我是第二块
- -``` - -我们也可以使用`p`段落标签,它一般用于文章分段: - -```html - -

- 你看这个彬彬啊,才喝几罐就醉了,真的太逊了。 这个彬彬就是逊呀! - 听你这么说,你很勇哦? 开玩笑,我超勇的,超会喝的啦。 - 超会喝,很勇嘛。身材不错哦,蛮结实的嘛。 -

-

- 哎,杰哥,你干嘛啊。都几岁了,还那么害羞!我看你,完全是不懂哦! - 懂,懂什么啊? 你想懂?我房里有一些好康的。 - 好康,是新游戏哦! 什么新游戏,比游戏还刺激! -

-

- 杰哥,这是什么啊? 哎呦,你脸红啦!来,让我看看。 - 不要啦!! 让我看看嘛。 不要啦,杰哥,你干嘛啊! - 让我看看你法语正不正常啊! -

- -``` - -那么如果遇到特殊字符该怎么办呢?和XML一样,我们可以使用转义字符: - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fwww.51wendang.com%2Fpic%2F208288d7561926f359c6be84%2F1-352-jpg_6_0_______-356-0-0-356.jpg&refer=http%3A%2F%2Fwww.51wendang.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1639877607&t=bcc1fcfe8bae53e90c365a4fd8c00a1c) - -**注意:**多个连续的空格字符只能被识别为一个,如果需要连续多个必须使用转义字符,同时也不会识别换行,换行只会变成一个空格,需要换行必须使用`br`标签。 - -通过了解了HTML的一些基础语法,我们现在就知道一个页面大致是如何编写了。 - -### HTML常用标签 - -前面我们已经了解了HTML的基本语法规范,那么现在我们就来看看,有哪些常用的标签吧,首先是换行和分割线: - -* br 换行 -* hr 分割线 - -```html - -
- 我是一段文字
我是第二段文字 -
-
-
我是底部文字
- -``` - -标题一般用h1到h6表示,我们来看看效果: - -```html - -

一级标题

-

二级标题

-

三级标题

-

四级标题

-
五级标题
-
六级标题
-

我是正文内容,真不错。

- -``` - -现在我们来看看超链接,我们可以添加一个链接用于指向其他网站: - -```html -点击访问小破站 -``` - -我们也可以指定页面上的一个锚点进行滚动: - -```html - -跳转锚点 - - - - -
我是锚点
- - - - -``` - -每个元素都可以有一个id属性,我们只需要给元素添加一个id属性,就使用a标签可以跳转到一个指定锚点。 - -我们接着来看看列表元素,这是一个无需列表,其中每一个`li`表示一个列表项: - -```html -
    -
  • 一号选项
  • -
  • 二号选项
  • -
  • 三号选项
  • -
  • 四号选项
  • -
  • 五号选项
  • -
-``` - -我们也可以使用`ol`来显示一个有序列表: - -```html -
    -
  1. 一号选项
  2. -
  3. 二号选项
  4. -
  5. 三号选项
  6. -
  7. 四号选项
  8. -
  9. 五号选项
  10. -
-``` - -表格也是很重要的一种元素,但是它编写起来相对有一点麻烦: - -```html - - - - - - - - - - - - - - - - - - - - - - - -
学号姓名性别年级
0001小明2019
0002小红2020
-``` - -虽然这样生成了一个表格,但是这个表格并没有分割线,并且格式也不符合我们想要的样式,那么如何才能修改这些基础属性的样式呢,我们就需要聊聊CSS了。 - -### HTML表单 - -表单就像其名字一样,用户在页面中填写了对应的内容,点击按钮就可以提交到后台,比如登陆界面,就可以使用表单来实现: - -一个网页中最重要的当属输入框和按钮了,那么我们来看看如何创建一个输入框和按钮: - -```html - -``` - -对于一个输入框,我们一般会将其包括在一个`lable`标签中,它和span效果一样,但是我们点击前面文字也能快速获取输入框焦点。 - -```html - -
登陆我们的网站
-
-
- -
-
- -
- -``` - -输入框可以有很多类型,我们来试试看password,现在输入内容就不会直接展示原文了。 - -创建一个按钮有以下几种方式,在学习JavaWeb时,我们更推荐第二种方式,我们后面进行登陆操作需要配合表单使用: - -```html - - - -``` - -现在我们就可以写一个大致的登陆页面了: - -```html - -

登陆我们的网站

-
-
- -
-
- -
-
- 忘记密码 -
-
-
- -
-
- -``` - -表单一般使用`form`标签将其囊括,但是现在我们还用不到表单提交,因此之后我们再来讲解表单的提交。 - -`input`只能实现单行文本,那么如何实现多行文本呢? - -```html - -``` - -我们还可以指定默认的行数和列数,拖动左下角可以自定义文本框的大小。 - -我们还可以在页面中添加勾选框: - -```html - -``` - -上面演示的是一个多选框,那么我们来看看单选框: - -```html - - -``` - -这里需要使用name属性进行分组,同一个组内的选项只能选择一个。 - -我们也可以添加列表让用户进行选择,创建一个下拉列表: - -```html - -``` - -默认选取的是第一个选项,我们可以通过`selected`属性来决定默认使用的是哪个选项。 - -当然,HTML的元素远不止我们所提到的这些,有关更多HTML元素的内容,可以自行了解。 - -*** - -## CSS样式 - -之前我们编写的页面非常基础,我们只能通过一些很基本的属性来排列我们的页面元素,那么如何实现更高度的自定义呢,我们就需要用到CSS来自定义样式,首先我们创建一个名为`style.css`的文件。 - -首先在我们HTML文件的头部添加: - -```html - -``` - -我们在CSS文件中添加以下内容: - -```css -body { - text-align: center; -} -``` - -我们发现,网页的内容全部变为居中显示了,这正是css在生效,相当于我们现在给页面添加了自定义的样式规则。 - -当然,我们也可以选择不使用CSS,而是直接对某个元素添加样式: - -```html - - ... -``` - -这样的效果其实是等同于上面的css文件的,相当于我们直接把样式定义在指定元素上。 - -也可以在头部直接定义样式,而不是使用外部文件: - -```html - -``` - -使用以上三种方式都可以自定义页面的样式,我们推荐使用还是第一种,不然我们的代码会很繁杂。 - -样式的属性是非常多的,我们不可能一个一个全部讲完,视频中用到什么再来讲解什么,如果同学们感兴趣,可以自行下去了解。 - -### CSS选择器 - -我们首先来了解一下选择器,那么什么是选择器呢?我们想要自定义一个元素的样式,那么我们肯定要去选择某个元素,只有先找到要自定义的元素,我们才能开始编写样式。 - -我们上面的例子中使用的就是标签名选择器,它可以快速选择页面中所有指定的的标签,比如我们之前使用的就是`body`标签,那么就相当于页面中所有的body元素全都使用此样式,那么我们现在来试试看选择页面中所有的`input`标签: - -```css -input { - width: 200px; -} -``` - -我们发现,页面中所有的`input`元素宽度全部被设定为了200个像素(`px`是单位大小,代表像素,除了`px`还有`em`和`rem`,他们是根据当前元素字体大小决定的相对大小,一般用于适配各种大小的浏览器窗口,这里暂时不用) - -样式编写完成后,如果只有一个属性,可以不带`;`若多个属性则每个属性后面都需要添加一个`;` - -因此,一个标签选择器的格式为: - -```css -标签名称 { - 属性名称: 属性值 -} -``` - -我们还可以设定输入框的字体大小、行高等: - -```css -input { - width: 200px; - font-size: 20px; - line-height: 40px; -} -``` - -我们现在可以通过选择器快速地去设置某个元素样式了,那么如何实现只设置某个元素的样式呢,现在我们来看看,id选择器,我们之前已经讲解过了,每个元素都可以有一个id属性,我们可以将其当做一个跳转的锚点使用,而现在,我们可以使用css来进行定位: - -我们先为元素添加id属性: - -```html -

登陆我们的网站

-``` - -现在使用CSS选择我们的元素,并设定一个属性,选择某个id需要在前面加上一个`#`: - -```css -#title { - color: red; -} -``` - -虽然id选择器已经可以很方便的指定某个元素,但是如果我们希望n个但不是元素都被选择,id选择器就无法实现了,因为每个元素的id是唯一的,不允许出现重复id的元素,因此接着我们来讲解一下类选择器。 - -每个元素都可以有一个`class`属性,表示当前元素属于某个类(注意这里的类和我们Java中的类概念完全不同)一个元素可以属于很多个类,一个类也可以被很多个元素使用: - -```html -
-
- -
-
- -
-
-``` - -上面的例子中,两个`label`元素都使用了`test`类(类名称是我们自定义的),现在我们在css文件中编写以下内容来以类进行选择: - -``` css -.test{ - color: blue; -} -``` - -我们发现,两个标签的文本内容都变为了蓝色,因此使用类选择器,能够对所有为此类的元素添加样式。注意在进行类选择时,我们需要在类名前面加上`.`来表示。 - -### 组合选择器和优先级问题 - -我们也可以让多个选择器,共用一个css样式: - -```css -.test, #title { - color: red; -} -``` - -只需要并排写即可,注意中间需要添加一个英文的逗号用于分割,我们也可以使用`*`来一次性选择所有的元素: - -```css -* { - color: red; -} -``` - -我们还可以选择位于某个元素内的某个元素: - -```css -div label { - color: red; -} -``` - -这样的话,就会选择所有位于div元素中的label元素。 - -当然,我们这里只介绍了一些常用的选择器,有关详细的CSS选择器可以查阅:https://www.runoob.com/cssref/css-selectors.html - -我们接着来看一下选择器的优先级: - -![img](https://img2020.cnblogs.com/blog/1864877/202004/1864877-20200408234042787-674324294.png) - -我们根据上面的信息,来测试一下,首先编写一下HTML文件: - -```html - -
我是测试文本内容
- -``` - -现在我们来编写一下css文件: - -```css -.test { - color: yellow; -} - -#simple { - color: red; -} - -* { - color: palegreen; -} -``` - -那么现在我们可以看到,实际上生效的是我们直接编写在标签内部的内联属性,那么现在我们依次进行移除,来看看它们的优先级。 - -那么如果我们希望某个属性无视任何的优先级,我们可以在属性后面添加`!important`标记,表示此属性是一个重要属性,它的优先级会被置为最高。 - -**思考:**那要是我每个选择器的这个属性后面都加一个`!important`会怎么样? - -### 自定义边距 - -我们来看看,如何使用css控制一个div板块的样式,首先编写以下代码,相当于一个div嵌套了一个div元素: - -```html -
-
- -
-
-``` - -现在编写一下自定义的css样式,我们将div设定为固定大小,并且背景颜色添加为绿色: - -```css -#outer { - background: palegreen; - width: 300px; - height: 300px; -} -``` - -我们发现左侧快速预览页面存在空隙,这是因为浏览器给我们添加了一个边距属性,我们只需要覆盖此属性并将其设定为0即可: - -```css -body { - margin: 0; -} -``` - -现在我们给内部嵌套的div也设定一个大小,并将颜色设定为橙色: - -```css -#inner { - background: darkorange; - width: 100px; - height: 100px; -} -``` - -现在我们发现内部的div元素位于右上角,我们还可以以百分比的形式来指定大小: - -```css -#inner { - background: darkorange; - width: 100%; - height: 100%; -} -``` - -百分比会依照当前可用大小来进行分配,比如当前位于一个div内部,并且外部div元素是固定大小300px,因此100%就相当于使用了外部的全部大小,也是300px,现在内部元素完全将外部元素覆盖了,整个元素现在呈现为橙色。 - -我们可以为一个元素设定边距,边距分为外边距和内边距,外部元素内边距决定了内部元素与外部元素之间的间隔,我们来修改一下css样式: - -```css -#outer { - background: palegreen; - width: 300px; - height: 300px; - padding: 10px; -} -``` - -我们发现,内部的div元素小了一圈,这是因为外部div元素设定了内边距,上下左右都被设定为10px大小。 - -而我们发现,实际上我们在一开始也是将body的外边距设定为了0,整个页面跟浏览器窗口直接间隔0px的宽度。 - -### 编写一个漂亮的登陆界面 - -现在我们就来尝试编写一个漂亮的登陆界面吧! - -*** - -## JavaScript语言 - -也称为js,是我们整个前端基础的重点内容,只有了解了JavaScript语言,我们才能了解前端如何与后端交互。 - -JavaScript与Java没有毛关系,仅仅只是名字中包含了Java而已,跟Java比起来,它更像Python,它是一门解释型语言,不需要进行编译,它甚至可以直接在浏览器的命令窗口中运行。 - -它相当于是前端静态页面的一个补充,它可以让一个普通的页面在后台执行一些程序,比如我们点击一个按钮,我们可能希望执行某些操作,比如下载文件、页面跳转、页面弹窗、进行登陆等,都可以使用JavaScript来帮助我们实现。 - -我们来看看一个简单的JavaScript程序: - -```js -const arr = [0, 2, 1, 5, 9, 3, 4, 6, 7, 8] - -for (let i = 0; i < arr.length; i++) { - for (let j = 0; j < arr.length - 1; j++) { - if(arr[j] > arr[j+1]){ - const tmp = arr[j] - arr[j] = arr[j+1] - arr[j+1] = tmp - } - } -} - -window.alert(arr) -``` - -这段代码实际上就是实现了一个冒泡排序算法,我们可以直接在页面的头部中引用此js文件,浏览器会在加载时自动执行js文件中编写的内容: - -```html - -``` - -我们发现JS的语法和Java非常相似,但是它还是和Java存在一些不同之处,而且存在很多阴间语法,那么我们来看看JS的语法。 - -### JavaScript基本语法 - -在js中,定义变量和Java中有一些不同,定义一个变量可以使用`let`关键字或是`var`关键字,IDEA推荐我们使用`let`关键字,因为`var`存在一定的设计缺陷(这里就不做讲解了,之后一律使用let关键字进行变量声明): - -```js -let a = 10; -a++; -window.alert(a) -``` - -上面的结果中,我们得到了a的结果是11,也就是说自增和自减运算在JS中也是支持的,并且JS每一句结尾可以不用加分号。 - -js并不是Java那样的强类型语言(任意变量的类型一定是明确的),它是一门弱类型语言,变量的类型并不会在一开始确定,因此我们在定义变量时无需指定变量的确切类型,而是在运行时动态解析类型: - -```js -let a = 10; -a = "HelloWorld!" -console.info(a) -``` - -我们发现,变量a已经被赋值为数字类型,但是我们依然在后续能将其赋值一个字符串,它的类型是随时可变的。 - -很多人说,这种变态的类型机制是JS的一大缺陷。 - -世界上只有两种语言:一种是很多人骂的,一种是没人用的。 - -我们接着来看看,JS中存在的基本数据类型: - -* Number:数字类型(包括小数和整数) -* String:字符串类型(可以使用单引号或是双引号) -* Boolean:布尔类型(与Java一致) - -还包括一些特殊值: - -* undefined:未定义 - 变量声明但不赋值默认为undefined - -* null:空值 - 等同于Java中的null - -* NaN:非数字 - 值不是合法数字,比如: - - ```js - window.alert(100/'xx') - ``` - -我们可以使用`typeof`关键字来查看当前变量值的类型: - -```js -let a = 10; -console.info(typeof a) -a = 'Hello World' -console.info(typeof a) -``` - -### JavaScript逻辑运算和流程控制 - -我们接着来看看js中的关系运算符,包括如下8个关系运算符:大于(>),小于(<),小于等于(<=),大于等于(>=),相等(==),不等(!=),全等(===),不全等(!==) - -其实关系运算符大致和Java中的使用方法一致,不过它还可以进行字符串比较,有点像C++的语法: - -```js -console.info(666 > 777) -console.info('aa' > 'ab') -``` - -那么,相等和全等有什么区别呢? - -```java -console.info('10' == 10) -console.info('10' === 10) -``` - -我们发现,在Java中,若运算符两边是不同的基本数据类型,会直接得到false,而JS中却不像这样,我们发现字符串的10居然等于数字10,而使用全等判断才是我们希望的结果。 - -`==`的比较规则是:当操作数类型一样时,比较的规则和恒等运算符一样,都相等才相等,如果两个操作数是字符串,则进行字符串的比较,如果里面有一个操作数不是字符串,那两个操作数通过Number()方法进行转换,转成数字进行比较。 - -因此,我们上面进行的判断实际上是运算符两边都进行了数字转换的结果进行比较,自然也就得到了true,而全等判断才是我们在Java中认识的相等判断。 - -我们接着来看逻辑运算,JS中包括&&、||、&、|、?:等,我们先来看看位运算符: - -```js -console.info(4 & 7) -console.info(4 | 7) -``` - -实际上和Java中是一样的,那么我再来看看逻辑运算: - -```js -console.info(true || false) -``` - -对于boolean变量的判断,是与Java一致的,但是JS也可以使用非Boolen类型变量进行判断: - -```js -console.info(!0) -console.info(!1) -``` - -和C/C++语言一样,0代表false,非0代表true,那么字符串呢? - -```js -console.info(!"a") -console.info(!"") -``` - -我们发现,空串为false,非空串为true,我们再来看看: - -```js -console.info(true || 7) -console.info(7 || true) -``` - -我们发现,前者得到的结果为true,而后者得到的结果却是是7,真是滑天下之大稽,什么鬼玩意,实际上是因为,默认非0都是true,而后者又是先判断的7,因此会直接得到7而不是被转换为true - -那么我们再来看看几个特殊值默认代表什么: - -```js -console.info(!undefined) -console.info(!null) -console.info(!NaN) -``` - -最后来使用一下三元运算符,实际上和Java中是一样的: - -```js -let a = true ? "xx" : 20 -console.info(a) -``` - -得益于JS的动态类型,emmm,三元运算符不一定需要固定的返回值类型。 - -JS的分支结构,实际上和Java是一样的,也是使用if-else语句来进行: - -```js -if("lbwnb"){ //非空串为true - console.info("!!!") -} else { - console.info("???") -} -``` - -同理,多分支语句也能实现: - -```js -if(""){ - console.info("!!!") -} else if(-666){ - console.info("???") -} else { - console.info("O.O") -} -``` - -当然,多分支语句也可以使用switch来完成: - -```js -let a = "a" -switch (a){ - case "a": - console.info("1") - break - case "b": - console.info("2") - break - case "c": - console.info("3") - break - default: - console.info("4") -} -``` - -接着我们来看看循环结构,其实循环结构也和Java相差不大: - -```js -let i = 10 -while(i--){ - console.info("100") -} -``` - -```js -for (let i = 0; i < 10; i++) { - console.info("??") -} -``` - -### JavaScript函数定义 - -JS中的方法和Java中的方法定义不太一样,JS中一般称其为函数,我们来看看定义一个函数的格式是什么: - -```js -function f() { - console.info("有一个人前来买瓜") -} -``` - -定义一个函数,需要在前面加上`function`关键字表示这是一个函数,后面跟上函数名称和`()`,其中可以包含参数,在`{}`中编写函数代码。我们只需要直接使用函数名+`()`就能调用函数: - -```js -f(); -``` - -我们接着来看一下,如何给函数添加形式参数以及返回值: - -```js -function f(a) { - console.info("得到的实参为:"+a) - return 666 -} - -f("aa"); -``` - -由于JS是动态类型,因此我们不必指明参数a的类型,同时也不必指明返回值的类型,一个函数可能返回不同类型的结果,因此直接编写return语句即可。同理,我们可以在调用函数时,不传参,那么默认会使用undefined: - -```js -function f(a) { - console.info("得到的实参为:"+a) - return 666 -} - -f(); -``` - -那么如果我们希望不传参的时候使用我们自定义的默认值呢? - -```js -function f(a = "6666") { - console.info("得到的实参为:"+a) - return 666 -} - -f(); -``` - -我们可以直接在形参后面指定默认值。 - -函数本身也是一种类型,他可以被变量接收,所有函数类型的变量,也可以直接被调用: - -```js -function f(a = "6666") { - console.info("得到的实参为:"+a) - return 666 -} - -let k = f; -k(); -``` - -我们也可以直接将匿名函数赋值给变量: - -```js -let f = function (str) { - console.info("实参为:"+str) -} -``` - -既然函数是一种类型,那么函数也能作为一个参数进行传递: - -```js -function f(test) { - test(); -} - -f(function () { - console.info("这是一个匿名函数") -}) -``` - -对于所有的匿名函数,可以像Java的匿名接口实现一样编写lambda表达式: - -```js -function f(test) { - test(); -} - -f(() => { - console.info("可以,不跟你多bb") -}) -``` - -```js -function f(test) { - test("这个是回调参数"); -} - -f(param => { - console.info("接受到回调参数:"+param) -}) -``` - -### JavaScript数组和对象 - -JS中的数组定义与Java不同,它更像是Python中的列表,数组中的每个元素并不需要时同样的类型: - -```js -let arr = [1, "lbwnb", false, undefined, NaN] -``` - -我们可以直接使用下标来访问: - -```js -let arr = [1, "lbwnb", false, undefined, NaN] -console.info(arr[1]) -``` - -我们一开始编写的排序算法,也是使用了数组。 - -数组还可以动态扩容,如果我们尝试访问超出数组长度的元素,并不会出现错误,而是得到undefined,同样的,我们也可以直接往超出数组长度的地方设置元素: - -```js -let arr = [1, "lbwnb", false, undefined, NaN] -arr[5] = "???" -console.info(arr) -``` - -也可以使用`push`和`pop`来实现栈操作: - -```js -let arr = [1, "lbwnb", false, undefined, NaN] -arr.push("bbb") -console.info(arr.pop()) -console.info(arr) -``` - -数组还包括一些其他的方法,这里就不一一列出了: - -```js -let arr = [1, "lbwnb", false, undefined, NaN] -arr.fill(1) -console.info(arr.map(o => { - return 'xxx'+o -})) -``` - -我们接着来看对象,JS中也能定义对象,但是这里的对象有点颠覆我们的认知: - -```js -let obj = new Object() -let obj = {} -``` - -以上两种写法都能够创建一个对象,但是更推荐使用下面的一种。 - -JS中的对象也是非常随意的,我们可以动态为其添加属性: - -```js -let obj = {} -obj.name = "伞兵一号" -console.info(obj) -``` - -同理,我们也可以给对象动态添加一个函数: - -```js -let obj = {} -obj.f = function (){ - console.info("我是对象内部的函数") -} - -obj.f() -``` - -我们可以在函数内使用this关键字来指定对象内的属性: - -```js -let name = "我是外部变量" -let obj = {} -obj.name = "我是内部变量" -obj.f = function (){ - console.info("name属性为:"+this.name) -} - -obj.f() -``` - -**注意:**如果使用lambda表达式,那么this并不会指向对象。 - -除了动态添加属性,我们也可以在一开始的时候指定对象内部的成员: - -```js -let obj = { - name: "我是内部的变量", - f: function (){ - console.info("name属性为:"+this.name) - } -} - -obj.f() -``` - -注意如果有多行属性,需要在属性定义后添加一个`,`进行分割! - -### JavaScript事件 - -当我们点击一个页面中的按钮之后,我们希望之后能够进行登陆操作,或是执行一些JS代码来实现某些功能,那么这个时候,就需要用到事件。 - -事件相当于一个通知,我们可以提前设定好事件发生时需要执行的内容,当事件发生时,就会执行我们预先设定好的JS代码。 - -事件有很多种类型,其中常用的有: - -* onclick:点击事件 -* oninput:内容输入事件 -* onsubmit:内容提交事件 - -那么如何为事件添加一个动作呢? - -```html - -``` - -我们可以直接为一个元素添加对应事件的属性,比如`oninput`事件,我们可以直接在事件的值中编写js代码,但是注意,只能使用单引号,因为双引号用于囊括整个值。 - -我们也可以单独编写一个函数,当事件发生时直接调用我们的函数: - -```js -function f() { - window.alert("你输入了一个字符") -} -``` - -```html - -``` - -仅仅了解了事件,还不足以实现高度自定义,我们接着来看DOM。 - -### Document对象 - -当网页被加载时,浏览器会创建页面的文档对象模型(*D*ocument *O*bject *M*odel),它将整个页面的所有元素全部映射为JS对象,这样我们就可以在JS中操纵页面中的元素。 - -![DOM HTML 树](https://www.w3school.com.cn/i/ct_htmltree.gif) - -比如我现在想要读取页面中某个输入框中的内容,那么我们就需要从DOM中获取此输入框元素的对象: - -```js -document.getElementById("pwd").value -``` - -通过document对象就能够快速获取当前页面中对应的元素,并且我们也可以快速获取元素中的一些属性。 - -比如现在我们可以结合事件,来进行密码长度的校验,密码长度小于6则不合法,不合法的密码,会让密码框边框变红,那么首先我们先来编写一个css样式: - -```css -.illegal-pwd{ - border: red 1px solid !important; - box-shadow: 0 0 5px red; -} -``` - -接着我们来编写一下js代码,定义一个函数,此函数接受一个参数(元素本身的对象)检测输入的长度是否大于6,否则就将当前元素的class属性设定为css指定的class: - -```js -function checkIllegal(e) { - if(e.value.length < 6) { - e.setAttribute("class", "illegal-pwd") - }else { - e.removeAttribute("class") - } -} -``` - -最后我们将此函数绑定到`oninput`事件即可,注意传入了一个this,这里的this代表的是输入框元素本身: - -```html - -``` - -现在我们在输入的时候,会自动检查密码是否合法。 - -既然oninput本身也是一个属性,那么实际上我们可以动态进行修改: - -```js -document.getElementById("pwd").oninput = () => console.info("???") -``` - -那么,我们前面提及的window对象又是什么东西呢? - -实际上Window对象范围更加广阔,它甚至直接代表了整个窗口,当然也包含我们的Document对象,我们一般通过Window对象来弹出提示框之类的东西。 - -### 发送XHR请求 - -JS的大致内容我们已经全部学习完成了,那么如何使用JS与后端进行交互呢? - -我们知道,如果我们需要提交表单,那么我们就需要将表单的信息全部发送给我们的服务器,那么,如何发送给服务器呢? - -通过使用XMLHttpRequest对象,来向服务器发送一个HTTP请求,下面是一个最简单的请求格式: - -```js -let xhr = new XMLHttpRequest(); -xhr.open('GET', 'https://www.baidu.com'); -xhr.send(); -``` - -上面的例子中,我们向服务器发起了一次网络请求,但是我们请求的是百度的服务器,并且此请求的方法为GET请求。 - -我们现在将其绑定到一个按钮上作为事件触发: - -```js -function http() { - let xhr = new XMLHttpRequest(); - xhr.open('GET', 'https://www.baidu.com'); - xhr.send(); -} -``` - -```html - -``` - -我们可以在网络中查看我们发起的HTTP请求并且查看请求的响应结果,比如上面的请求,会返回百度这个页面的全部HTML代码。 - -实际上,我们的浏览器在我们输入网址后,也会向对应网站的服务器发起一次HTTP的GET请求。 - -在浏览器得到页面响应后,会加载当前页面,如果当前页面还引用了其他资源文件,那么会继续向服务器发起请求,直到页面中所有的资源文件全部加载完成后,才会停止。 - diff --git a/青空笔记/Java设计模式笔记/Java设计模式(一).md b/青空笔记/Java设计模式笔记/Java设计模式(一).md deleted file mode 100644 index dfe5624..0000000 --- a/青空笔记/Java设计模式笔记/Java设计模式(一).md +++ /dev/null @@ -1,554 +0,0 @@ -![image-20220513191747324](https://tva1.sinaimg.cn/large/e6c9d24egy1h26ztcf6xbj21hy0i2abh.jpg) - -# 面向对象设计原则 - -**注意:**推荐完成JavaEE通关路线再开始学习。 - -我们在进行软件开发时,不仅仅需要将最基本的业务给完成,还要考虑整个项目的可维护性和可复用性,我们开发的项目不单单需要我们自己来维护,同时也需要其他的开发者一起来进行共同维护,因此我们在编写代码时,应该尽可能的规范。如果我们在编写代码时不注重这些问题,整个团队项目就像一座屎山,随着项目的不断扩大,整体结构只会越来越遭。 - -甚至到最后你会发现,我们的程序居然是稳定运行在BUG之上的... - -所以,为了尽可能避免这种情况的发生,我们就来聊聊面向对象设计原则。 - -## 单一职责原则 - -单一职责原则(Simple Responsibility Pinciple,SRP)是最简单的面向对象设计原则,它用于控制类的粒度大小。 - -> 一个对象应该只包含单一的职责,并且该职责被完整地封装在一个类中。 - -比如我们现在有一个People类: - -```java -//一个人类 -public class People { - - /** - * 人类会编程 - */ - public void coding(){ - System.out.println("int mian() {"); - System.out.println(" printf(\"Holle Wrold!\");"); - System.out.println("}"); - System.out.println("啊嘞,怎么运行不起?明明照着老师敲的啊"); - } - - /** - * 工厂打螺丝也会 - */ - public void work(){ - System.out.println("真开心,能进到富土康打螺丝"); - System.out.println("诶,怎么工友都提桶跑路了"); - } - - /** - * 送外卖也会 - */ - public void ride(){ - System.out.println("今天终于通过美团最终面,加入了梦寐以求的大厂了"); - System.out.println("感觉面试挺简单的,就是不知道为啥我同学是现场做一道力扣接雨水,而我是现场问会不会骑车"); - System.out.println("(迫不及待穿上外卖服装)"); - } -} -``` - -我们可以看到,这个People类可以说是十八般武艺样样精通了,啥都会,但是实际上,我们每个人最终都是在自己所擅长的领域工作,所谓闻道有先后,术业有专攻,会编程的就应该是程序员,会打螺丝的就应该是工人,会送外卖的应该是骑手,显然这个People太过臃肿(我们需要修改任意一种行为都需要修改People类,它拥有不止一个引起它变化的原因),所以根据单一职责原则,我们下需要进行更明确的划分,同种类型的操作我们一般才放在一起: - -```java -class Coder{ - /** - * 程序员会编程 - */ - public void coding(){ - System.out.println("int mian() {"); - System.out.println(" printf(\"Hello World!\")"); - System.out.println("}"); - System.out.println("啊嘞,怎么运行不起?明明照着老师敲的啊"); - } -} - -class Worker{ - /** - * 工人会打螺丝 - */ - public void work(){ - System.out.println("真开心,能进到富土康打螺丝"); - System.out.println("诶,怎么工友都提桶跑路了"); - } -} - -class Rider { - /** - * 骑手会送外卖 - */ - public void ride(){ - System.out.println("今天终于通过美团最终面,加入了梦寐以求的大厂"); - System.out.println("感觉面试挺简单的,就是不知道为啥我同学是现场做一道力扣接雨水,我是现场问会不会骑车"); - System.out.println("(迫不及待穿上外卖服装)"); - } -} -``` - -我们将类的粒度进行更近一步的划分,这样就很清晰了,包括我们以后在设计Mapper、Service、Controller等等,根据不同的业务进行划分,都可以采用单一职责原则,以它作为我们实现高内聚低耦合的指导方针。实际上我们的微服务也是参考了单一职责原则,每个微服务只应担负一个职责。 - -## 开闭原则 - -开闭原则(Open Close Principle)也是重要的面向对象设计原则。 - -> 软件实体应当对扩展开放,对修改关闭。 - -一个软件实体,比如类、模块和函数应该对扩展开放,对修改关闭。其中,对扩展开放是针对提供方来说的,对修改关闭是针对调用方来说的。 - -比如我们的程序员分为Java程序员、C#程序员、C艹程序员、PHP程序员、前端程序员等,而他们要做的都是去打代码,而具体如何打代码是根据不同语言的程序员来决定的,我们可以将程序员打代码这一个行为抽象成一个统一的接口或是抽象类,这样我们就满足了开闭原则的第一个要求:对扩展开放,不同的程序员可以自由地决定他们该如何进行编程。而具体哪个程序员使用什么语言怎么编程,是自己在负责,不需要其他程序员干涉,所以满足第二个要求:对修改关闭,比如: - -```java -public abstract class Coder { - - public abstract void coding(); - - class JavaCoder extends Coder{ - @Override - public void coding() { - System.out.println("Java太卷了T_T,快去学Go吧!"); - } - } - - class PHPCoder extends Coder{ - @Override - public void coding() { - System.out.println("PHP是世界上最好的语言"); - } - } - - class C艹Coder extends Coder{ - @Override - public void coding() { - System.out.println("笑死,Java再牛逼底层不还得找我?"); - } - } -} -``` - -通过提供一个Coder抽象类,定义出编程的行为,但是不进行实现,而是开放给其他具体类型的程序员来实现,这样就可以根据不同的业务进行灵活扩展了,具有较好的延续性。 - -不过,回顾我们这一路的学习,好像处处都在使用开闭原则。 - -## 里氏替换原则 - -里氏替换原则(Liskov Substitution Principle)是对子类型的特别定义。它由芭芭拉·利斯科夫(Barbara Liskov)在1987年在一次会议上名为 "数据的抽象与层次" 的演说中首先提出。 - -> 所有引用基类的地方必须能透明地使用其子类的对象。 - -简单的说就是,子类可以扩展父类的功能,但不能改变父类原有的功能: - -1. 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。 -2. 子类可以增加自己特有的方法。 -3. 当子类的方法重载父类的方法时,方法的前置条件(即方法的输入/入参)要比父类方法的输入参数更宽松。 -4. 当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即方法的输出/返回值)要比父类更严格或与父类一样。 - -比如我们下面的例子: - -```java -public abstract class Coder { - - public void coding() { - System.out.println("我会打代码"); - } - - - class JavaCoder extends Coder{ - - /** - * 子类除了会打代码之外,还会打游戏 - */ - public void game(){ - System.out.println("艾欧尼亚最强王者已上号"); - } - } -} -``` - -可以看到JavaCoder虽然继承自Coder,但是并没有对父类方法进行重写,并且还在父类的基础上进行额外扩展,符合里氏替换原则。但是我们再来看下面的这个例子: - -```java -public abstract class Coder { - - public void coding() { - System.out.println("我会打代码"); - } - - - class JavaCoder extends Coder{ - public void game(){ - System.out.println("艾欧尼亚最强王者已上号"); - } - - /** - * 这里我们对父类的行为进行了重写,现在它不再具备父类原本的能力了 - */ - public void coding() { - System.out.println("我寒窗苦读十六年,到最后还不如培训班三个月出来的程序员"); - System.out.println("想来想去,房子车子结婚彩礼,为什么这辈子要活的这么累呢?"); - System.out.println("难道来到这世间走这一遭就为了花一辈子时间买个房子吗?一个人不是也能活的轻松快乐吗?"); - System.out.println("摆烂了,啊对对对"); - //好了,emo结束,继续卷吧,人生因奋斗而美丽,这个世界虽然满目疮痍,但是还是有很多美好值得期待 - } - } -} -``` - -可以看到,现在我们对父类的方法进行了重写,显然,父类的行为已经被我们给覆盖了,这个子类已经不具备父类的原本的行为,很显然违背了里氏替换原则。 - -要是程序员连敲代码都不会了,还能叫做程序员吗? - -所以,对于这种情况,我们不需要再继承自Coder了,我们可以提升一下,将此行为定义到People中: - -```java -public abstract class People { - - public abstract void coding(); //这个行为还是定义出来,但是不实现 - - class Coder extends People{ - @Override - public void coding() { - System.out.println("我会打代码"); - } - } - - - class JavaCoder extends People{ - public void game(){ - System.out.println("艾欧尼亚最强王者已上号"); - } - - public void coding() { - System.out.println("摆烂了,啊对对对"); - } - } -} -``` - -里氏替换也是实现开闭原则的重要方式之一。 - -## 依赖倒转原则 - -依赖倒转原则(Dependence Inversion Principle)也是我们一直在使用的,最明显的就是我们的Spring框架了。 - -> 高层模块不应依赖于底层模块,它们都应该依赖抽象。抽象不应依赖于细节,细节应该依赖于抽象。 - -还记得我们在我们之前的学习中为什么要一直使用接口来进行功能定义,然后再去实现吗?我们回顾一下在使用Spring框架之前的情况: - -```java -public class Main { - - public static void main(String[] args) { - UserController controller = new UserController(); - //该怎么用就这么用 - } - - static class UserMapper { - //CRUD... - } - - static class UserService { - UserMapper mapper = new UserMapper(); - //业务代码.... - } - - static class UserController { - UserService service = new UserService(); - //业务代码.... - } -} -``` - -但是突然有一天,公司业务需求变化,现在用户相关的业务操作需要使用新的实现: - -```java -public class Main { - - public static void main(String[] args) { - UserController controller = new UserController(); - } - - static class UserMapper { - //CRUD... - } - - static class UserServiceNew { //由于UserServiceNew发生变化,会直接影响到其他高层模块 - UserMapper mapper = new UserMapper(); - //业务代码.... - } - - static class UserController { //焯,干嘛改底层啊,我这又得重写了 - UserService service = new UserService(); //哦豁,原来的不能用了 - UserServiceNew serviceNew = new UserServiceNew(); //只能修改成新的了 - //业务代码.... - } -} -``` - -我们发现,我们的各个模块之间实际上是具有强关联的,一个模块是直接指定依赖于另一个模块,虽然这样结构清晰,但是底层模块的变动,会直接影响到其他依赖于它的高层模块,如果我们的项目变得很庞大,那么这样的修改将是一场灾难。 - -而有了Spring框架之后,我们的开发模式就发生了变化: - -```java -public class Main { - - public static void main(String[] args) { - UserController controller = new UserController(); - } - - interface UserMapper { - //接口中只做CRUD方法定义 - } - - static class UserMapperImpl implements UserMapper { - //实现类完成CRUD具体实现 - } - - interface UserService { - //业务代码定义.... - } - - static class UserServiceImpl implements UserService { - @Resource //现在由Spring来为我们选择一个指定的实现类,然后注入,而不是由我们在类中硬编码进行指定 - UserMapper mapper; - - //业务代码具体实现 - } - - static class UserController { - @Resource - UserService service; //直接使用接口,就算你改实现,我也不需要再修改代码了 - - //业务代码.... - } -} -``` - -可以看到,通过使用接口,我们就可以将原有的强关联给弱化,我们只需要知道接口中定义了什么方法然后去使用即可,而具体的操作由接口的实现类来完成,并由Spring来为我们注入,而不是我们通过硬编码的方式去指定。 - -## 接口隔离原则 - -接口隔离原则(Interface Segregation Principle, ISP)实际上是对接口的细化。 - -> 客户端不应依赖那些它不需要的接口。 - -我们在定义接口的时候,一定要注意控制接口的粒度,比如下面的例子: - -```java -interface Device { - String getCpu(); - String getType(); - String getMemory(); -} - -//电脑就是一种电子设备,那么我们就实现此接口 -class Computer implements Device { - - @Override - public String getCpu() { - return "i9-12900K"; - } - - @Override - public String getType() { - return "电脑"; - } - - @Override - public String getMemory() { - return "32G DDR5"; - } -} - -//电风扇也算是一种电子设备 -class Fan implements Device { - - @Override - public String getCpu() { - return null; //就一个破风扇,还需要CPU? - } - - @Override - public String getType() { - return "风扇"; - } - - @Override - public String getMemory() { - return null; //风扇也不需要内存吧 - } -} -``` - -虽然我们定义了一个Device接口,但是由于此接口的粒度不够细,虽然比较契合电脑这种设备,但是不适合风扇这种设备,因为风扇压根就不需要CPU和内存,所以风扇完全不需要这些方法。这时我们就必须要对其进行更细粒度的划分: - -```java -interface SmartDevice { //智能设备才有getCpu和getMemory - String getCpu(); - String getType(); - String getMemory(); -} - -interface NormalDevice { //普通设备只有getType - String getType(); -} - -//电脑就是一种电子设备,那么我们就继承此接口 -class Computer implements SmartDevice { - - @Override - public String getCpu() { - return "i9-12900K"; - } - - @Override - public String getType() { - return "电脑"; - } - - @Override - public String getMemory() { - return "32G DDR5"; - } -} - -//电风扇也算是一种电子设备 -class Fan implements NormalDevice { - @Override - public String getType() { - return "风扇"; - } -} -``` - -这样,我们就将接口进行了细粒度的划分,不同类型的电子设备就可以根据划分去实现不同的接口了。当然,也不能划分得太小,还是要根据实际情况来进行决定。 - -## 合成复用原则 - -合成复用原则(Composite Reuse Principle)的核心就是委派。 - -> 优先使用对象组合,而不是通过继承来达到复用的目的。 - -在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分,新的对象通过向这些对象的委派达到复用已有功能的目的。实际上我们在考虑将某个类通过继承关系在子类得到父类已经实现的方法之外(比如A类实现了连接数据库的功能,恰巧B类中也需要,我们就可以通过继承来获得A已经写好的连接数据库的功能,这样就能直接复用A中已经写好的逻辑)我们应该应该优先地去考虑使用合成的方式来实现复用。 - -比如下面这个例子: - -```java -class A { - public void connectDatabase(){ - System.out.println("我是连接数据库操作!"); - } -} - -class B extends A{ //直接通过继承的方式,得到A的数据库连接逻辑 - public void test(){ - System.out.println("我是B的方法,我也需要连接数据库!"); - connectDatabase(); //直接调用父类方法就行 - } -} -``` - -虽然这样看起来没啥毛病,但是还是存在我们之前说的那个问题,耦合度太高了。 - -可以看到通过继承的方式实现复用,我们是将类B直接指定继承自类A的,那么如果有一天,由于业务的更改,我们的数据库连接操作,不再由A来负责,而是由新来的C去负责,那么这个时候,我们就不得不将需要复用A中方法的子类全部进行修改,很显然这样是费时费力的。 - -并且还有一个问题就是,通过继承子类会得到一些父类中的实现细节,比如某些字段或是方法,这样直接暴露给子类,并不安全。 - -所以,当我们需要实现复用时,可以优先考虑以下操作: - -```java -class A { - public void connectDatabase(){ - System.out.println("我是连接数据库操作!"); - } -} - -class B { //不进行继承,而是在用的时候给我一个A,当然也可以抽象成一个接口,更加灵活 - public void test(A a){ - System.out.println("我是B的方法,我也需要连接数据库!"); - a.connectDatabase(); //在通过传入的对象A去执行 - } -} -``` - -或是: - -```java -class A { - public void connectDatabase(){ - System.out.println("我是连接数据库操作!"); - } -} - -class B { - - A a; - public B(A a){ //在构造时就指定好 - this.a = a; - } - - public void test(){ - System.out.println("我是B的方法,我也需要连接数据库!"); - a.connectDatabase(); //也是通过对象A去执行 - } -} -``` - -通过对象之间的组合,我们就大大降低了类之间的耦合度,并且A的实现细节我们也不会直接得到了。 - -## 迪米特法则 - -迪米特法则(Law of Demeter)又称最少知识原则,是对程序内部数据交互的限制。 - -> 每一个软件单位对其他单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。 - -简单来说就是,一个类/模块对其他的类/模块有越少的交互越好。当一个类发生改动,那么,与其相关的类(比如用到此类啥方法的类)需要尽可能少的受影响(比如修改了方法名、字段名等,可能其他用到这些方法或是字段的类也需要跟着修改)这样我们在维护项目的时候会更加轻松一些。 - -其实说白了,还是降低耦合度,我们还是来看一个例子: - -```java -public class Main { - public static void main(String[] args) throws IOException { - Socket socket = new Socket("localhost", 8080); //假设我们当前的程序需要进行网络通信 - Test test = new Test(); - test.test(socket); //现在需要执行test方法来做一些事情 - } - - static class Test { - /** - * 比如test方法需要得到我们当前Socket连接的本地地址 - */ - public void test(Socket socket){ - System.out.println("IP地址:"+socket.getLocalAddress()); - } - } -} -``` - -可以看到,虽然上面这种写法没有问题,我们提供直接提供一个Socket对象,然后再由test方法来取出IP地址,但是这样显然违背了迪米特法则,实际上这里的`test`方法只需要一个IP地址即可,我们完全可以直接传入一个字符串,而不是整个Socket对象,我们需要保证与其他类的交互尽可能的少。 - -就像我们在餐厅吃完了饭,应该是我们自己扫码付款,而不是直接把手机交给老板来帮你操作付款。 - -要是某一天,Socket类中的这些方法发生修改了,那我们就得连带着去修改这些类,很麻烦。 - -所以,我们来改进改进: - -```java -public class Main { - public static void main(String[] args) throws IOException { - Socket socket = new Socket("localhost", 8080); - Test test = new Test(); - test.test(socket.getLocalAddress().getHostAddress()); //在外面解析好就行了 - } - - static class Test { - public void test(String str){ //一个字符串就能搞定,就没必要丢整个对象进来 - System.out.println("IP地址:"+str); - } - } -} -``` - -这样,类与类之间的耦合度再次降低。 diff --git a/青空笔记/Java设计模式笔记/Java设计模式(三).md b/青空笔记/Java设计模式笔记/Java设计模式(三).md deleted file mode 100644 index e8d3261..0000000 --- a/青空笔记/Java设计模式笔记/Java设计模式(三).md +++ /dev/null @@ -1,664 +0,0 @@ -![image-20220522171355473](https://tva1.sinaimg.cn/large/e6c9d24egy1h2hat6hcrvj21fi0dcdhg.jpg) - -# 设计模式(结构型) - -结构型设计模式关注如何将现有的类或对象组织在一起形成更加强大的结构。并且根据我们前面学习的合成复用原则,我们该如何尽可能地使用关联关系来代替继承关系是我们本版块需要重点学习的内容。 - -## 类/对象适配器模式 - -在生活中,我们经常遇到这样的一个问题:笔记本太轻薄了,以至于没有RJ45网口和USB A口(比如Macbook为了轻薄甚至全是type-c形式的雷电口)但是现在我们因为工作需要,又得使用这些接口来连接线缆,这时我们想到的第一个解决方案,就是去买一个转接口(扩展坞),扩展坞可以将type-c口转换为其他类型的接口供我们使用,实际上这就是一种适配模式。 - -![image-20220523002617557](https://tva1.sinaimg.cn/large/e6c9d24egy1h2hnb1v5ytj21e20be401.jpg) - -由于我们的电脑没有这些接口,但是提供了type-c类型的接口,虽然接口类型不一样,但是同样可以做其他接口能做的事情,比如USB文件传输、有线网络连接等,所以,这个时候,我们只需要添加一个中间人来帮我们转换一下接口形态即可。包括我们常用的充电头,为什么叫电源适配器呢?我们知道传统的供电是220V交流电,但是我们的手机可能只需要5V的电压进行充电,虽然现在有电,但是不能直接充,我们也不可能让电力公司专门为我们提供一个5V的直流电使用。这时电源适配器就开始发挥作用了,比如苹果的祖传5V1A充电头,实际上就是将220V交流电转换为5V的直流电进行传输,这样就相当于在220V交流电和我们的手机之前,做了一个适配器的角色。 - -在我们的Java程序中,也会经常遇到这样的问题,比如: - -```java -public class TestSupplier { //手机供应商 - - public String doSupply(){ - return "iPhone 14 Pro"; - } -} -``` - -```java -public class Main { - public static void main(String[] args) { - TestSupplier supplier = new TestSupplier(); - test( ? ); //我们没有Target类型的手机供应商,只有其他的,那这里该填个啥 - } - - public static void test(Target target){ //现在我们需要调用test方法,但是test方法需要Target类型的手机供应商 - System.out.println("成功得到:"+target.supply()); - } -} -``` - -```java -public interface Target { //现在的手机供应商,并不是test方法所需要的那种类型 - - String supply(); -} -``` - -这个时候,我们就可以使用适配器模式了,适配器模式分为类适配器和对象适配器,我们首先来看看如何使用类适配器解决这种问题,我们直接创建一个适配器类: - -```java -public class TestAdapter extends TestSupplier implements Target { - //让我们的适配器继承TestSupplier并且实现Target接口 - @Override - public String supply() { //接着实现supply方法,直接使用TestSupplier提供的实现 - return super.doSupply(); - } -} -``` - -这样,我们就得到了一个Target类型的实现类,并且同时采用的是TestSupplier提供的实现。 - -```java -public static void main(String[] args) { - TestAdapter adapter = new TestAdapter(); - test(adapter); -} - -public static void test(Target target){ - System.out.println("成功得到:"+target.supply()); -} -``` - -不过,这种实现方式需要占用一个继承坑位,如果此时Target不是接口而是抽像类的话,由于Java不支持多继承,那么就无法实现了。同时根据合成复用原则,我们应该更多的通过合成的方式去实现功能,所以我们来看看第二种,也是用的比较多的一种模式,对象适配器: - -```java -public class TestAdapter implements Target{ //现在不再继承TestSupplier,仅实现Target - - TestSupplier supplier; - - public TestAdapter(TestSupplier supplier){ - this.supplier = supplier; - } - - @Override - public String supply() { - return supplier.doSupply(); - } -} -``` - -现在,我们就将对象以组合的形式存放在TestAdapter中,依然是通过存放的对象调用具体实现。 - -## 桥接模式 - -相信各位都去奶茶店买过奶茶,在购买奶茶的时候,店员首先会问我们,您需要什么类型的奶茶,比如我们此时点了一杯啵啵芋圆奶茶,接着店员会直接问我们需要大杯、中杯还是小杯,最后还会询问我们需要加什么配料,比如椰果、珍珠等,最后才会给我们制作奶茶。 - -![image-20220523233253071](https://tva1.sinaimg.cn/large/e6c9d24egy1h2irdudr55j20w00ba40h.jpg) - -那么现在让你来设计一下这种模式的Java类,该怎么做呢?首先我们要明确,一杯奶茶除了类型之外,还分大中小杯,甚至可能还分加什么配料,这个时候,如果我们按照接口实现的写法: - -```java -public interface Tea { //由具体类型的奶茶实现 - String getType(); //不同的奶茶返回的类型不同 -} -``` - -```java -public interface Size { //分大杯小杯中杯 - String getSize(); -} -``` - -比如现在我们创建一个新的类型: - -```java -/** - * 大杯芋圆啵啵奶茶 - */ -public class LargeKissTea implements Tea, Size{ - - @Override - public String getSize() { - return "大杯"; - } - - @Override - public String getType() { - return "芋圆啵啵奶茶"; - } -} -``` - -虽然这样设计起来还挺合理的,但是如果现在我们的奶茶品种多起来了,并且每种奶茶都有大中小杯,现在一共有两个维度需要考虑,那么我们岂不是得一个一个去创建这些类?甚至如果还要考虑配料,那么光创建类就得创建不知道多少个了。显然这种设计不太好,我们得换个方式。 - -这时,就可以使用我们的桥接模式了,现在我们面临的问题是,维度太多,不可能各种类型各种尺寸的奶茶都去创建一个类,那么我们就还是单独对这些接口进行简单的扩展,单独对不同的维度进行控制,但是如何实现呢?我们不妨将奶茶的类型作为最基本的抽象类,然后对尺寸、配料等属性进行桥接: - -```java -public abstract class AbstractTea { - - protected Size size; //尺寸作为桥接属性存放在类中 - - protected AbstractTea(Size size){ //在构造时需要知道尺寸属性 - this.size = size; - } - - public abstract String getType(); //具体类型依然是由子类决定 -} -``` - -不过这个抽象类提供的方法还不全面,仅仅只有Tea的getType方法,我们还需要添加其他维度的方法,所以继续编写一个子类: - -```java -public abstract class RefinedAbstractTea extends AbstractTea{ - protected RefinedAbstractTea(Size size) { - super(size); - } - - public String getSize(){ //添加尺寸维度获取方式 - return size.getSize(); - } -} -``` - -现在我们只需要单独为Size创建子类即可: - -```java -public class Large implements Size{ - - @Override - public String getSize() { - return "大杯"; - } -} -``` - -现在我们如果需要一个大杯的啵啵芋圆奶茶,只需要: - -```java -public class KissTea extends RefinedAbstractTea{ //创建一个啵啵芋圆奶茶的子类 - protected KissTea(Size size) { //在构造时需要指定具体的大小实现 - super(size); - } - - @Override - public String getType() { - return "啵啵芋圆奶茶"; //返回奶茶类型 - } -} -``` - -现在我们就将两个维度拆开,可以分别进行配置了: - -```java -public static void main(String[] args) { - KissTea tea = new KissTea(new Large()); - System.out.println(tea.getType()); - System.out.println(tea.getSize()); -} -``` - -通过桥接模式,使得抽象和实现可以沿着各自的维度来进行变化,不再是固定的绑定关系。 - -## 组合模式 - -组合模式实际上就是将多个组件进行组合,让用户可以对它们进行一致性处理。比如我们的文件夹,一个文件夹中可以有很多个子文件夹或是文件: - -![image-20220524001804908](https://tva1.sinaimg.cn/large/e6c9d24egy1h2isot6rdnj213c072jsu.jpg) - -它就像是一个树形结构一样,有分支有叶子,而组合模式则是可以对整个树形结构上的所有节点进行递归处理,比如我们现在希望将所有文件夹中的文件的名称前面都添加一个前缀,那么就可以使用组合模式。 - -![image-20220524083222697](https://tva1.sinaimg.cn/large/e6c9d24ely1h2j6z4r7nej21p60mgdhd.jpg) - -组合模式的示例如下,这里我们就用文件和文件夹的例子来讲解: - -```java -/** - * 首先创建一个组件抽象,组件可以包含组件,组件有自己的业务方法 - */ -public abstract class Component { - public abstract void addComponent(Component component); //添加子组件 - public abstract void removeComponent(Component component); //删除子组件 - public abstract Component getChild(int index); //获取子组件 - public abstract void test(); //执行对应的业务方法,比如修改文件名称 -} -``` - -接着我们来编写两种实现类: - -```java -public class Directory extends Component{ //目录可以包含多个文件或目录 - - List child = new ArrayList<>(); //这里我们使用List来存放目录中的子组件 - - @Override - public void addComponent(Component component) { - child.add(component); - } - - @Override - public void removeComponent(Component component) { - child.remove(component); - } - - @Override - public Component getChild(int index) { - return child.get(index); - } - - @Override - public void test() { - child.forEach(Component::test); //将继续调用所有子组件的test方法执行业务 - } -} -``` - -```java -public class File extends Component{ //文件就相当于是树叶,无法再继续添加子组件了 - - @Override - public void addComponent(Component component) { - throw new UnsupportedOperationException(); //不支持这些操作了 - } - - @Override - public void removeComponent(Component component) { - throw new UnsupportedOperationException(); - } - - @Override - public Component getChild(int index) { - throw new UnsupportedOperationException(); - } - - @Override - public void test() { - System.out.println("文件名称修改成功!"+this); //具体的名称修改操作 - } -} -``` - -最后,我们来测试一下: - -```java -public static void main(String[] args) { - Directory outer = new Directory(); //新建一个外层目录 - Directory inner = new Directory(); //新建一个内层目录 - outer.addComponent(inner); - outer.addComponent(new File()); //在内层目录和外层目录都添加点文件,注意别导错包了 - inner.addComponent(new File()); - inner.addComponent(new File()); - outer.test(); //开始执行文件名称修改操作 -} -``` - -可以看到我们对最外层目录进行操作后,会递归向下处理当前目录和子目录中所有的文件。 - -## 装饰模式 - -装饰模式就像其名字一样,为了对现有的类进行装饰。比如一张相片就一张纸,如果直接贴在墙上,总感觉少了点什么,但是我们给其添加一个好看的相框,就会变得非常对味。装饰模式的核心就在于不改变一个对象本身功能的基础上,给对象添加额外的行为,并且它是通过组合的形式完成的,而不是传统的继承关系。 - -比如我们现在有一个普通的功能类: - -```java -public abstract class Base { //顶层抽象类,定义了一个test方法执行业务 - public abstract void test(); -} -``` - -```java -public class BaseImpl extends Base{ - @Override - public void test() { - System.out.println("我是业务方法"); //具体的业务方法 - } -} -``` - -不过现在的实现类太单调了,我们来添加一点装饰上去: - -```java -public class Decorator extends Base{ //装饰者需要将装饰目标组合到类中 - - protected Base base; - - public Decorator(Base base) { - this.base = base; - } - - @Override - public void test() { - base.test(); //这里暂时还是使用目标的原本方法实现 - } -} -``` - -```java -public class DecoratorImpl extends Decorator{ //装饰实现 - - public DecoratorImpl(Base base) { - super(base); - } - - @Override - public void test() { //对原本的方法进行装饰,我们可以在前后都去添加额外操作 - System.out.println("装饰方法:我是操作前逻辑"); - super.test(); - System.out.println("装饰方法:我是操作后逻辑"); - } -} -``` - -这样,我们就通过装饰模式对类的功能进行了扩展: - -```java -public static void main(String[] args) { - Base base = new BaseImpl(); - Decorator decorator = new DecoratorImpl(base); //将Base实现装饰一下 - Decorator outer = new DecoratorImpl(decorator); //装饰者还可以嵌套 - - decorator.test(); - - outer.test(); -} -``` - -这样我们就实现了装饰模式。 - -## 代理模式 - -代理模式和装饰模式很像,初学者很容易搞混,所以这里我们得紧接着来讲解一下。首先请记住,当无法直接访问某个对象或访问某个对象存在困难时,我们就可以通过一个代理对象来间接访问。 - -实际上代理在我们生活中处处都存在,比如手机厂商要去销售手机,但是手机厂商本身没有什么渠道可以大规模地进行售卖,很难与这些消费者进行对接,这时就得交给代理商去进行出售,比如Apple在中国的直营店很少,但是在中国的授权经销商却很多,手机厂商通过交给旗下代理商的形式来进行更大规模的出售。比如我们经常要访问Github,但是直接连接会发现很难连的上,这时我们加了一个代理就可以轻松访问,也是在体现代理的作用。 - -![image-20220524110803328](https://tva1.sinaimg.cn/large/e6c9d24ely1h2jbh4qkf3j21ga0ioaf6.jpg) - -同时,代理类需要保证客户端使用的透明性,也就是说操作起来需要与原本的真实对象相同,比如我们访问Github只需要输入网址即可访问,而添加代理之后,也是使用同样的方式去访问Github,所以操作起来是一样的。包括Spring框架其实也是依靠代理模式去实现的AOP记录日志等。 - -比如现在有一个目标类,但是我们现在需要通过代理来使用它: - -```java -public abstract class Subject { - public abstract void test(); -} -``` - -```java -public class SubjectImpl extends Subject{ //此类无法直接使用,需要我们进行代理 - - @Override - public void test() { - System.out.println("我是测试方法!"); - } -} -``` - -现在我们为其建立一个代理类: - -```java -public class Proxy extends Subject{ //为了保证和Subject操作方式一样,保证透明性,也得继承 - - Subject target; //被代理的对象(甚至可以多重代理) - - public Proxy(Subject subject){ - this.target = subject; - } - - @Override - public void test() { //由代理去执行被代理对象的方法,并且我们还可以在前后添油加醋 - System.out.println("代理前绕方法"); - target.test(); - System.out.println("代理后绕方法"); - } -} -``` - -乍一看,这不跟之前的装饰模式一模一样吗? - -对装饰器模式来说,装饰者和被装饰者都实现同一个接口/抽象类。对代理模式来说,代理类和被代理的类都实现同一个接口/抽象类,在结构上确实没有啥区别。但是他们的作用不同,装饰器模式强调的是增强自身,在被装饰之后你能够在被增强的类上使用增强后的功能,增强后你还是你,只不过被强化了而已;代理模式强调要让别人帮你去做事情,以及添加一些本身与你业务没有太多关系的事情(记录日志、设置缓存等)重点在于让别人帮你做。 - -装饰模式和代理模式的不同之处在于思想。 - -当然实现代理模式除了我们上面所说的这种方式之外,我们还可以使用JDK为我们提供的动态代理机制,我们不再需要手动编写继承关系创建代理类,它能够在运行时通过反射机制为我们自动生成代理类: - -```java -public interface Subject { //JDK提供的动态代理只支持接口 - void test(); -} -``` - -```java -public class SubjectImpl implements Subject{ - - @Override - public void test() { - System.out.println("我是测试方法!"); - } -} -``` - -接着我们需要创建一个动态代理的处理逻辑: - -```java -public class TestProxy implements InvocationHandler { //代理类,需要实现InvocationHandler接口 - - private final Object object; //这里需要保存一下被代理的对象,下面需要用到 - - public TestProxy(Object object) { - this.object = object; - } - - @Override //此方法就是调用代理对象的对应方法时会进入,这里我们就需要编写如何进行代理了 - public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { - //method就是调用的代理对象的哪一个方法,args是实参数组 - System.out.println("代理的对象:"+proxy.getClass()); //proxy就是生成的代理对象了,我们看看是什么类型的 - Object res = method.invoke(object, args); //在代理中调用被代理对象原本的方法,因为你是代理,还是得执行一下别人的业务,当然也可以不执行,但是这样就失去代理的意义了,注意要用上面的object - System.out.println("方法调用完成,返回值为:"+res); //看看返回值是什么 - return res; //返回返回值 - } -} -``` - -最后我们来看看如何创建一个代理类: - -```java -public static void main(String[] args) { - SubjectImpl subject = new SubjectImpl(); //被代理的大冤种 - InvocationHandler handler = new TestProxy(subject); - Subject proxy = (Subject) Proxy.newProxyInstance( - subject.getClass().getClassLoader(), //需要传入被代理的类的类加载器 - subject.getClass().getInterfaces(), //需要传入被代理的类的接口列表 - handler); //最后传入我们实现的代理处理逻辑实现类 - proxy.test(); //比如现在我们调用代理类的test方法,那么就会进入到我们上面TestProxy中invoke方法,走我们的代理逻辑 -} -``` - -运行一次,可以看到调用代理类的方法,最终会走到我们的invoke方法中进行: - -![image-20220524141757961](https://tva1.sinaimg.cn/large/e6c9d24ely1h2jgypioutj214403cmxd.jpg) - -根据接口,代理对象是`com.sun.proxy.$Proxy0`类(看名字就知道不对劲),这个类是动态生成的,我们也找不到具体的源代码。 - -不过JDK提供的动态代理只能使用接口,如果换成我们一开始的抽象类,就没办法了,这时我们可以使用一些第三方框架来实现更多方式的动态代理,比如Spring都在使用的CGLib框架,Maven依赖如下: - -```xml - - cglib - cglib - 3.1 - -``` - -由于CGlib底层使用ASM框架(JVM篇视频教程有介绍)进行字节码编辑,所以能够实现不仅仅局限于对接口的代理: - -```java -public class TestProxy implements MethodInterceptor { //首先还是编写我们的代理逻辑 - - private final Object target; //这些和之前JDK动态代理写法是一样的 - - public TestProxy(Object target) { - this.target = target; - } - - @Override //我们也是需要在这里去编写我们的代理逻辑 - public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable { - System.out.println("现在是由CGLib进行代理操作!"+o.getClass()); - return method.invoke(target, objects); //也是直接调用代理对象的方法即可 - } -} -``` - -接着我们来创建一下代理类: - -```java -public static void main(String[] args) { - SubjectImpl subject = new SubjectImpl(); - - Enhancer enhancer = new Enhancer(); //增强器,一会就需要依靠增强器来为我们生成动态代理对象 - enhancer.setSuperclass(SubjectImpl.class); //直接选择我们需要代理的类型,直接不需要接口或是抽象类,SuperClass作为代理类的父类存在,这样我们就可以按照指定类型的方式去操作代理类了 - enhancer.setCallback(new TestProxy(subject)); //设定我们刚刚编写好的代理逻辑 - - SubjectImpl proxy = (SubjectImpl) enhancer.create(); //直接创建代理类 - - proxy.test(); //调用代理类的test方法 -} -``` - -可以看到,效果其实是差不多的: - -![image-20220524143920720](https://tva1.sinaimg.cn/large/e6c9d24ely1h2jhky32gyj216y03ggm0.jpg) - -可以看到代理类是`包名.SubjectImpl$$EnhancerByCGLIB$$47f6ed3a`,也是动态生成的一个类,所以我们无法去查看源码,不过此类是继承自我们指定的类型的。 - -## 外观模式 - -你是否经历过类似的情况:今年计算机学院的奖学金评定工作开始了,由于你去年一不小心拿了个ACM的区域赛金牌,觉得自己又行了,于是也想参与到奖学金的争夺中,首先你的辅导员会通知你去打印你的获奖材料,然后你高高兴兴拿给辅导员之后,辅导员又给了你一张表,让你打印了之后填写一下,包括你的个人信息还有一些个人介绍,完成后,你本以为可以坐等发奖了,结果辅导员又跟你说我们评定还要去某某地方盖章,盖完章还要去找谁谁谁签字,最后还要参加一下答辩... 看着如此复杂的流程,你瞬间不想搞了。 - -![image-20220524150153791](https://tva1.sinaimg.cn/large/e6c9d24ely1h2ji8fk7ddj21e40d2dhh.jpg) - -实际上我们生活中很多时候都是这样,可能在办一件事情的时候,由于部门职能的不同,你得各个部门到处跑,你肯定会抱怨一句,就不能有个人来统一一下吗,就不能在一个地方一起把事情都办了吗?这时,我们就可以用到外观模式了。 - -外观模式充分体现了迪米特法则。可能我们的整个项目有很多个子系统,但是我们可以在这些子系统的上面加一个门面(Facade)当我们外部需要与各个子系统交互时,无需再去直接使用各个子系统,而是与门面进行交互,再由门面与后面的各个子系统操作,这样,我们以后需要办什么事情,就统一找门面就行了。这样的好处是,首先肯定方便了代码的编写,统一找门面就行,不需要去详细了解子系统,并且,当子系统需要修改时,也只需要修改门面中的逻辑,不需要大面积的变动,遵循迪米特法则尽可能少的交互。 - -![image-20220524150617434](https://tva1.sinaimg.cn/large/e6c9d24ely1h2jiczopulj21cm0eaq4l.jpg) - -比如现在我们设计了三个子系统,分别是排队、结婚、领证,正常情况下我们是需要分别去找这三个部门去完成的,但是现在我们通过门面统一来完成: - -```java -public class SubSystemA { - public void test1(){ - System.out.println("排队"); - } -} -``` - -```java -public class SubSystemB { - public void test2(){ - System.out.println("结婚"); - } -} -``` - -```java -public class SubSystemC { - public void test3(){ - System.out.println("领证"); - } -} -``` - -现在三个系统太复杂了,我们添加一个门面: - -```java -public class Facade { - - SubSystemA a = new SubSystemA(); - SubSystemB b = new SubSystemB(); - SubSystemC c = new SubSystemC(); - - public void marry(){ //红白喜事一条龙服务 - a.test1(); - b.test2(); - c.test3(); - } -} -``` - -现在我们只需要一个门面就能直接把事情办完了: - -```java -public static void main(String[] args) { - Facade facade = new Facade(); - facade.marry(); -} -``` - -通过使用外观模式,我们就大大降低了类与类直接的关联程度,并且简化了流程。 - -## 享元模式 - -最后我们来看看享元模式(Flyweight),那么这个"享元"代表什么意思呢?我们先来看看下面的问题: - -```java -public static void main(String[] args) { - String str1 = "abcdefg"; - String str2 = "abcd"; -} -``` - -我们发现上面的例子中,两个字符串虽然长短不同,但是却包含了一段相同的部分,那么现在我们如果要对内存进行优化: - -```java -public static void main(String[] args) { - String str1 = "efg"; //由于str1包含str2,所以我们可以去掉重复的部分,当需要原本的str1时,再合在一起 - String str2 = "abcd"; - System.out.println("str1 = "+str2+str1); -} -``` - -而享元模式就是这个思想,我们可以将那些重复出现的内容作为共享部分取出,这样当我们拥有大量对象时,我们把其中共同的部分抽取出来,由于提取的部分是多个对象共享只有一份,那么就可以减轻内存的压力。包括我们的围棋,实际上我们只需要知道棋盘上的各个位置是黑棋还是白棋,实际上没有毕业创建很多个棋子对象,我们只需要去复用一个黑棋和一个白棋子对象即可。 - -比如现在我们有两个服务,但是他们都需要使用数据库工具类来操作,实际上这个工具类没必要创建多个,我们这时就可以使用享元模式,让数据库工具类作为享元类,通过享元工厂来提供一个共享的数据库工具类: - -```java -public class DBUtil { - public void selectDB(){ - System.out.println("我是数据库操作..."); - } -} -``` - -```java -public class DBUtilFactory { - private static final DBUtil UTIL = new DBUtil(); //享元对象被存放在工厂中 - - public static DBUtil getFlyweight(){ //获取享元对象 - return UTIL; - } -} -``` - -最后当我们需要使用享元对象时,直接找享元工厂要就行了: - -```java -public class UserService { //用户服务 - - public void service(){ - DBUtil util = DBUtilFactory.getFlyweight(); //通过享元工厂拿到DBUtil对象 - util.selectDB(); //该干嘛干嘛 - } -} -``` - -当然,这只是简单的享元模式实现,实际上我们一开始举例的String类,也在使用享元模式进行优化,比如下面的代码: - -```java -public static void main(String[] args) { - String str1 = "abcd"; - String str2 = "abcd"; - String str3 = "ab" + "cd"; - System.out.println(str1 == str2); - System.out.println(str1 == str3); //猜猜这三个对象是不是都是同一个? -} -``` - -虽然我们这里定义了三个字符串,但是我们发现,这三个对象指向的都是同一个对象,这是为什么呢?实际上这正是Java语言实现了数据的共享,想要了解具体实现请前往JVM篇视频教程。 diff --git a/青空笔记/Java设计模式笔记/Java设计模式(二).md b/青空笔记/Java设计模式笔记/Java设计模式(二).md deleted file mode 100644 index 558c1e8..0000000 --- a/青空笔记/Java设计模式笔记/Java设计模式(二).md +++ /dev/null @@ -1,491 +0,0 @@ -![image-20220521153312361](https://tva1.sinaimg.cn/large/e6c9d24egy1h2g2a4fy7rj21fc0hkwsb.jpg) - -# 设计模式(创建型) - -软件设计模式(Design pattern),又称设计模式,是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结。使用设计模式是为了可重用代码、让代码更容易被他人理解、保证代码可靠性、程序的重用性。 - -> 肯特·贝克和[沃德·坎宁安](https://baike.baidu.com/item/沃德·坎宁安/6488429)在1987年利用克里斯托佛·亚历山大在建筑设计领域里的思想开发了设计模式并把此思想应用在Smalltalk中的图形用户接口的生成中。一年后Erich Gamma在他的[苏黎世大学](https://baike.baidu.com/item/苏黎世大学/1621125)博士毕业论文中开始尝试把这种思想改写为适用于软件开发。与此同时James Coplien 在1989年至1991 年也在利用相同的思想致力于C++的开发,而后于1991年发表了他的著作Advanced C++ Idioms。就在这一年Erich Gamma 得到了博士学位,然后去了美国,在那与Richard Helm, Ralph Johnson ,John Vlissides合作出版了Design Patterns - Elements of Reusable Object-Oriented Software 一书,在此书中共收录了23个设计模式。这四位作者在软件开发领域里也以他们的匿名著称Gang of Four(四人帮,简称GoF),并且是他们在此书中的协作导致了软件设计模式的突破。 - -我们先来看看有关对象创建的几种设计模式。 - -## 工厂方法模式 - -首当其冲的是最简单的一种设计模式——工厂方法模式,我们知道,如果需要创建一个对象,那么最简单的方式就是直接new一个即可。而工厂方法模式代替了传统的直接new的形式,那么为什么要替代传统的new形式呢? - -可以想象一下,如果所有的对象我们都通过new的方式去创建,那么当我们的程序中大量使用此对象时,突然有一天这个对象的构造方法或是类名发生了修改,那我们岂不是得挨个去进行修改?根据迪米特法则,我们应该尽可能地少与其他类进行交互,所以我们可以将那些需要频繁出现的对象创建,封装到一个工厂类中,当我们需要对象时,直接调用工厂类中的工厂方法来为我们生成对象,这样,就算类出现了变动,我们也只需要修改工厂中的代码即可,而不是大面积地进行修改。 - -同时,可能某些对象的创建并不只是一个new就可以搞定,可能还需要更多的步骤来准备构造方法需要的参数,所以我们来看看如何使用`简单工厂模式`来创建对象,既然是工厂,那么我们就来创建点工厂需要生产的东西: - -```java -public abstract class Fruit { //水果抽象类 - private final String name; - - public Fruit(String name){ - this.name = name; - } - - @Override - public String toString() { - return name+"@"+hashCode(); //打印一下当前水果名称,还有对象的hashCode - } -} -``` - -```java -public class Apple extends Fruit{ //苹果,继承自水果 - - public Apple() { - super("苹果"); - } -} -``` - -```java -public class Orange extends Fruit{ //橘子,也是继承自水果 - public Orange() { - super("橘子"); - } -} -``` - -正常情况下,我们直接new就可以得到对象了: - -```java -public class Main { - public static void main(String[] args) { - Apple apple = new Apple(); - System.out.println(apple); - } -} -``` - -现在我们将对象的创建封装到工厂中: - -```java -public class FruitFactory { - /** - * 这里就直接来一个静态方法根据指定类型进行创建 - * @param type 水果类型 - * @return 对应的水果对象 - */ - public static Fruit getFruit(String type) { - switch (type) { - case "苹果": - return new Apple(); - case "橘子": - return new Orange(); - default: - return null; - } - } -} -``` - -现在我们就可以使用此工厂来创建对象了: - -```java -public class Main { - public static void main(String[] args) { - Fruit fruit = FruitFactory.getFruit("橘子"); //直接问工厂要,而不是我们自己去创建 - System.out.println(fruit); - } -} -``` - -不过这样还是有一些问题,我们前面提到了开闭原则,一个软件实体,比如类、模块和函数应该对扩展开放,对修改关闭,但是如果我们现在需要新增一种水果,比如桃子,那么这时我们就得去修改工厂提供的工厂方法了,但是这样是不太符合开闭原则的,因为工厂实际上是针对于调用方提供的,所以我们应该尽可能对修改关闭。 - -所以,我们就利用对扩展开放,对修改关闭的性质,将`简单工厂模式`改进为`工厂方法模式`,那现在既然不让改,那么我们就看看如何去使用扩展的形式: - -```java -public abstract class FruitFactory { //将水果工厂抽象为抽象类,添加泛型T由子类指定水果类型 - public abstract T getFruit(); //不同的水果工厂,通过此方法生产不同的水果 -} -``` - -```java -public class AppleFactory extends FruitFactory { //苹果工厂,直接返回Apple,一步到位 - @Override - public Apple getFruit() { - return new Apple(); - } -} -``` - -这样,我们就可以使用不同类型的工厂来生产不同类型的水果了,并且如果新增了水果类型,直接创建一个新的工厂类就行,不需要修改之前已经编写好的内容。 - -```java -public class Main { - public static void main(String[] args) { - test(new AppleFactory()::getFruit); //比如我们现在要吃一个苹果,那么就直接通过苹果工厂来获取苹果 - } - - //此方法模拟吃掉一个水果 - private static void test(Supplier supplier){ - System.out.println(supplier.get()+" 被吃掉了,真好吃。"); - } -} -``` - -这样,我们就简单实现了工厂方法模式,通过工厂来屏蔽对象的创建细节,使用者只需要关心如何去使用对象即可。 - -## 抽象工厂模式 - -前面我们介绍了工厂方法模式,通过定义顶层抽象工厂类,通过继承的方式,针对于每一个产品都提供一个工厂类用于创建。 - -不过这种模式只适用于简单对象,当我们需要生产许多个产品族的时候,这种模式就有点乏力了,比如: - -![image-20220521162444703](https://tva1.sinaimg.cn/large/e6c9d24egy1h2g3rpau36j218u0e0jt8.jpg) - -实际上这些产品都是成族出现的,比如小米的产品线上有小米12,小米平板等,华为的产品线上也有华为手机、华为平板,但是如果按照我们之前工厂方法模式来进行设计,那就需要单独设计9个工厂来生产上面这些产品,显然这样就比较浪费时间的。 - -但是现在有什么方法能够更好地处理这种情况呢?我们就可以使用抽象工厂模式,我们可以将多个产品,都放在一个工厂中进行生成,按不同的产品族进行划分,比如小米,那么我就可以安排一个小米工厂,而这个工厂里面就可以生产整条产品线上的内容,包括小米手机、小米平板、小米路由等。 - -所以,我们只需要建立一个抽象工厂即可: - -```java -public class Router { -} -``` - -```java -public class Table { -} -``` - -```java -public class Phone { -} -``` - -```java -public abstract class AbstractFactory { - public abstract Phone getPhone(); - public abstract Table getTable(); - public abstract Router getRouter(); -} -``` - -一个工厂可以生产同一个产品族的所有产品,这样按族进行分类,显然比之前的工厂方法模式更好。 - -不过,缺点还是有的,如果产品族新增了产品,那么我就不得不去为每一个产品族的工厂都去添加新产品的生产方法,违背了开闭原则。 - -## 建造者模式 - -建造者模式也是非常常见的一种设计模式,我们经常看到有很多的框架都为我们提供了形如`XXXBuilder`的类型,我们一般也是使用这些类来创建我们需要的对象。 - -比如,我们在JavaSE中就学习过的`StringBuiler`类: - -```java -public static void main(String[] args) { - StringBuilder builder = new StringBuilder(); //创建一个StringBuilder来逐步构建一个字符串 - builder.append(666); //拼接一个数字 - builder.append("老铁"); //拼接一个字符串 - builder.insert(2, '?'); //在第三个位置插入一个字符 - System.out.println(builder.toString()); //差不多成形了,最后转换为字符串 -} -``` - -实际上我们是通过建造者来不断配置参数或是内容,当我们配置完所有内容后,最后再进行对象的构建。 - -相比直接去new一个新的对象,建造者模式的重心更加关注在如何完成每一步的配置,同时如果一个类的构造方法参数过多,我们通过建造者模式来创建这个对象,会更加优雅。 - -比如我们现在有一个学生类: - -```java -public class Student { - int id; - int age; - int grade; - String name; - String college; - String profession; - List awards; - - public Student(int id, int age, int grade, String name, String college, String profession, List awards) { - this.id = id; - this.age = age; - this.grade = grade; - this.name = name; - this.college = college; - this.profession = profession; - this.awards = awards; - } -} -``` - -可以看到这个学生类的属性是非常多的,所以构造方法不是一般的长,如果我们现在直接通过new的方式去创建: - -```java -public static void main(String[] args) { - Student student = new Student(1, 18, 3, "小明", "计算机学院", "计算机科学与技术", Arrays.asList("ICPC-ACM 区域赛 金牌", "LPL 2022春季赛 冠军")); -} -``` - -可以看到,我们光是填参数就麻烦,我们还得一个一个对应着去填,一不小心可能就把参数填到错误的位置了。 - -所以,我们现在可以使用建造者模式来进行对象的创建: - -```java -public class Student { - ... - - //一律使用建造者来创建,不对外直接开放 - private Student(int id, int age, int grade, String name, String college, String profession, List awards) { - ... - } - - public static StudentBuilder builder(){ //通过builder方法直接获取建造者 - return new StudentBuilder(); - } - - public static class StudentBuilder{ //这里就直接创建一个内部类 - //Builder也需要将所有的参数都进行暂时保存,所以Student怎么定义的这里就怎么定义 - int id; - int age; - int grade; - String name; - String college; - String profession; - List awards; - - public StudentBuilder id(int id){ //直接调用建造者对应的方法,为对应的属性赋值 - this.id = id; - return this; //为了支持链式调用,这里直接返回建造者本身,下同 - } - - public StudentBuilder age(int age){ - this.age = age; - return this; - } - - ... - - public StudentBuilder awards(String... awards){ - this.awards = Arrays.asList(awards); - return this; - } - - public Student build(){ //最后我们只需要调用建造者提供的build方法即可根据我们的配置返回一个对象 - return new Student(id, age, grade, name, college, profession, awards); - } - } -} -``` - -现在,我们就可以使用建造者来为我们生成对象了: - -```java -public static void main(String[] args) { - Student student = Student.builder() //获取建造者 - .id(1) //逐步配置各个参数 - .age(18) - .grade(3) - .name("小明") - .awards("ICPC-ACM 区域赛 金牌", "LPL 2022春季赛 冠军") - .build(); //最后直接建造我们想要的对象 -} -``` - -这样,我们就可以让这些参数对号入座了,并且也比之前的方式优雅许多。 - -## 单例模式 - -单例模式其实在之前的课程中已经演示过很多次了,这也是使用频率非常高的一种模式。 - -那么,什么是单例模式呢?顾名思义,单例那么肯定就是只有一个实例对象,在我们的整个程序中,同一个类始终只会有一个对象来进行操作。比如数据库连接类,实际上我们只需要创建一个对象或是直接使用静态方法就可以了,没必要去创建多个对象。 - -这里还是还原一下我们之前使用的简单单例模式: - -```java -public class Singleton { - private final static Singleton INSTANCE = new Singleton(); //用于引用全局唯一的单例对象,在一开始就创建好 - - private Singleton() {} //不允许随便new,需要对象直接找getInstance - - public static Singleton getInstance(){ //获取全局唯一的单例对象 - return INSTANCE; - } -} -``` - -这样,当我们需要获取此对象时,只能通过`getInstance()`来获取唯一的对象: - -```java -public static void main(String[] args) { - Singleton singleton = Singleton.getInstance(); -} -``` - -当然,单例模式除了这种写法之外,还有其他写法,这种写法被称为饿汉式单例,也就是说在一开始类加载时就创建好了,我们来看看另一种写法——懒汉式: - -```java -public class Singleton { - private static Singleton INSTANCE; //在一开始先不进行对象创建 - - private Singleton() {} //不用多说了吧 - - public static Singleton getInstance(){ //将对象的创建延后到需要时再进行 - if(INSTANCE == null) { //如果实例为空,那么就进行创建,不为空说明已经创建过了,那么就直接返回 - INSTANCE = new Singleton(); - } - return INSTANCE; - } -} -``` - -可以看到,懒汉式就真的是条懒狗,你不去用它,它是不会给你提前准备单例对象的(延迟加载,懒加载),当我们需要获取对象时,才会进行检查并创建。虽然饿汉式和懒汉式写法不同,但是最后都是成功实现了单例模式。 - -不过,这里需要特别提醒一下,由于懒汉式是在方法中进行的初始化,在多线程环境下,可能会出现问题(建议学完JUC篇视频教程再来观看)大家可以试想一下,如果这个时候有多个线程同时调用了`getInstance()`方法,那么会出现什么问题呢? - -![image-20220522161134092](https://tva1.sinaimg.cn/large/e6c9d24egy1h2h90c124gj21ae0bedhw.jpg) - -可以看到,在多线程环境下,如果三条线程同时调用`getInstance()`方法,会同时进行`INSTANCE == null`的判断,那么此时由于确实还没有进行任何实例化,所以导致三条线程全部判断为`true`(而饿汉式由于在类加载时就创建完成,不会存在这样的问题)此时问题就来了,我们既然要使用单例模式,那么肯定是只希望对象只被初始化一次的,但是现在由于多线程的机制,导致对象被多次创建。 - -所以,为了避免线程安全问题,针对于懒汉式单例,我们还得进行一些改进: - -```java -public static synchronized Singleton getInstance(){ //方法必须添加synchronized关键字加锁 - if(INSTANCE == null) { - INSTANCE = new Singleton(); - } - return INSTANCE; -} -``` - -既然多个线程要调用,那么我们就直接加一把锁,在方法上添加`synchronized`关键字即可,这样同一时间只能有一个线程进入了。虽然这样简单粗暴,但是在高并发的情况下,效率肯定是比较低的,我们来看看如何进行优化: - -```java -public static Singleton getInstance(){ - if(INSTANCE == null) { - synchronized (Singleton.class) { //实际上只需要对赋值这一步进行加锁即可 - INSTANCE = new Singleton(); - } - } - return INSTANCE; -} -``` - -不过这样还不完美,因为这样还是有可能多个线程同时判断为`null`而进入等锁的状态,所以,我们还得加一层内层判断: - -```java -public static Singleton getInstance(){ - if(INSTANCE == null) { - synchronized (Singleton.class) { - if(INSTANCE == null) INSTANCE = new Singleton(); //内层还要进行一次检查,双重检查锁定 - } - } - return INSTANCE; -} -``` - -不过我们还少考虑了一样内容,其实IDEA此时应该是给了黄标了: - -![image-20220522162246278](https://tva1.sinaimg.cn/large/e6c9d24egy1h2h9by3dioj21be0aqmyd.jpg) - -可以看到,这种情况下,IDEA会要求我们添加一个`volatile`给`INSTANCE`,各位还记得这个关键字有什么作用吗?没错,我们还需要保证`INSTANCE`在线程之间的可见性,这样当其他线程进入之后才会拿`INSTANCE`由其他线程更新的最新值去判断,这样,就差不多完美了。 - -那么,有没有一种更好的,不用加锁的方式也能实现延迟加载的写法呢?我们可以使用静态内部类: - -```java -public class Singleton { - private Singleton() {} - - private static class Holder { //由静态内部类持有单例对象,但是根据类加载特性,我们仅使用Singleton类时,不会对静态内部类进行初始化 - private final static Singleton INSTANCE = new Singleton(); - } - - public static Singleton getInstance(){ //只有真正使用内部类时,才会进行类初始化 - return Holder.INSTANCE; //直接获取内部类中的 - } -} -``` - -这种方式显然是最完美的懒汉式解决方案,没有进行任何的加锁操作,也能保证线程安全,不过要实现这种写法,跟语言本身也有一定的关联,并不是所有的语言都支持这种写法。 - -## 原型模式 - -原型模式实际上与对象的拷贝息息相关,原型模式使用原型实例指定待创建对象的类型,并且通过复制这个原型来创建新的对象。也就是说,原型对象作为模板,通过克隆操作,来产生更多的对象,就像细胞的复制一样。 - -开始之前,我们先介绍一下对象的深拷贝和浅拷贝,首先我们来看浅拷贝: - -* **浅拷贝:**对于类中基本数据类型,会直接复制值给拷贝对象;对于引用类型,只会复制对象的地址,而实际上指向的还是原来的那个对象,拷贝个基莫。 - - ```java - public static void main(String[] args) { - int a = 10; - int b = a; //基本类型浅拷贝 - System.out.println(a == b); - - Object o = new Object(); - Object k = o; //引用类型浅拷贝,拷贝的仅仅是对上面对象的引用 - System.out.println(o == k); - } - ``` - -* **深拷贝:**无论是基本类型还是引用类型,深拷贝会将引用类型的所有内容,全部拷贝为一个新的对象,包括对象内部的所有成员变量,也会进行拷贝。 - -在Java中,我们就可以使用Cloneable接口提供的拷贝机制,来实现原型模式: - -```java -public class Student implements Cloneable{ //注意需要实现Cloneable接口 - @Override - public Object clone() throws CloneNotSupportedException { //提升clone方法的访问权限 - return super.clone(); - } -} -``` - -接着我们来看看克隆的对象是不是原来的对象: - -```java -public static void main(String[] args) throws CloneNotSupportedException { - Student student0 = new Student(); - Student student1 = (Student) student0.clone(); - System.out.println(student0); - System.out.println(student1); -} -``` - -可以看到,通过`clone()`方法克隆的对象并不是原来的对象,我们来看看如果对象内部有属性会不会一起进行克隆: - -```java -public class Student implements Cloneable{ - - String name; - - public Student(String name){ - this.name = name; - } - - public String getName() { - return name; - } - - @Override - public Object clone() throws CloneNotSupportedException { - return super.clone(); - } -} -``` - -```java -public static void main(String[] args) throws CloneNotSupportedException { - Student student0 = new Student("小明"); - Student student1 = (Student) student0.clone(); - System.out.println(student0.getName() == student1.getName()); -} -``` - -可以看到,虽然Student对象成功拷贝,但是其内层对象并没有进行拷贝,依然只是对象引用的复制,所以Java为我们提供的`clone`方法只会进行浅拷贝。那么如何才能实现深拷贝呢? - -```java -@Override -public Object clone() throws CloneNotSupportedException { //这里我们改进一下,针对成员变量也进行拷贝 - Student student = (Student) super.clone(); - student.name = new String(name); - return student; //成员拷贝完成后,再返回 -} -``` - -这样,我们就实现了深拷贝。 - diff --git a/青空笔记/Java设计模式笔记/Java设计模式(四).md b/青空笔记/Java设计模式笔记/Java设计模式(四).md deleted file mode 100644 index 4f9d47c..0000000 --- a/青空笔记/Java设计模式笔记/Java设计模式(四).md +++ /dev/null @@ -1,866 +0,0 @@ -![image-20220524160923707](https://tva1.sinaimg.cn/large/e6c9d24ely1h2jk6p4i6hj21fa0h677n.jpg) - -# 设计模式(行为型) - -前面我们已经学习了12种设计模式,分为两类: - -* 创建型:关注对象创建 -* 结构型:关注类和对象的结构组织 - -我们接着来看最后一种设计模式,也是最多的一种,行为型设计模式关注系统中对象之间的交互,研究系统在运行时对象之间的相互通信与协作,进一步明确对象的职责。 - -## 解释器模式 - -这种模式的使用场景较少,很少使用的一种设计模式,这里提一下就行。 - -解释器顾名思义,就是对我们的语言进行解释,根据不同的语义来做不同的事情,比如我们在SE中学习的双栈计算器,正是根据我们输入的算式,去进行解析,并根据不同的运算符来不断进行计算。 - -比如我们输入:1+2*3 - -那么计算器就会进行解析然后根据语义优先计算2*3的结果然后在计算1+6最后得到7,详细实现请参考JavaSE篇双栈计算器实现。 - -## 模板方法模式 - -模板方法模式我们之前也见到过许多,我们先来看看什么是模板方法。 - -有些时候,我们的业务可能需要经历很多个步骤来完成,比如我们生病了在医院看病,首先是去门诊挂号,然后等待叫号,然后是去找医生看病,确定病因后,就根据医生的处方去前台开药,最后付钱。这一整套流程看似是规规矩矩的,但是在这其中,某些步骤并不是确定的,比如医生看病这一步,由于不同的病因,可能会进行不同的处理,最后开出来的药方也会不同,所以,整套流程中,有些操作是固定的,有些操作可能需要根据具体情况而定。 - -![image-20220524164925635](https://tva1.sinaimg.cn/large/e6c9d24ely1h2jlcb4jmqj20yi084mxz.jpg) - -在我们的程序中也是如此,可能某些操作是固定的,我们就可以直接在类中对应方法进行编写,但是可能某些操作需要视情况而定,由不同的子类实现来决定,这时,我们就需要让这些操作由子类来延迟实现了。现在我们就需要用到模板方法模式。 - -我们先来写个例子: - -```java -/** - * 抽象诊断方法,因为现在只知道挂号和看医生是固定模式,剩下的开处方和拿药都是不确定的 - */ -public abstract class AbstractDiagnosis { - - public void test(){ - System.out.println("今天头好晕,不想起床,开摆,先跟公司请个假"); - System.out.println("去医院看病了~"); - System.out.println("1 >> 先挂号"); - System.out.println("2 >> 等待叫号"); - //由于现在不知道该开什么处方,所以只能先定义一下行为,然后具体由子类实现 - //大致的流程先定义好就行 - this.prescribe(); - this.medicine(); //开药同理 - } - - public abstract void prescribe(); //开处方操作根据具体病症决定了 - - public abstract void medicine(); //拿药也是根据具体的处方去拿 -} -``` - -现在我们定义好了抽象方法,只是将具体的流程先定义出来了,但是部分方法需要根据实现决定: - -```java -/** - * 感冒相关的具体实现子类 - */ -public class ColdDiagnosis extends AbstractDiagnosis{ - @Override - public void prescribe() { - System.out.println("3 >> 一眼丁真,鉴定为假,你这不是感冒,纯粹是想摆烂"); - } - - @Override - public void medicine() { - System.out.println("4 >> 开点头孢回去吃吧"); - } -} -``` - -这样,我们就有了一个具体的实现类,并且由于看病的逻辑已经由父类定义好了,所以子类只需要实现需要实现的部分即可,这样我们就实现了简单的模板方法模式: - -```java -public static void main(String[] args) { - AbstractDiagnosis diagnosis = new ColdDiagnosis(); - diagnosis.test(); -} -``` - -![image-20220524171621919](https://tva1.sinaimg.cn/large/e6c9d24ely1h2jm4bz3mej217k06s3z7.jpg) - -最后我们来看看在JUC中讲解AQS源码实现中出现的代码: - -```java -public final boolean release(int arg) { //AQS的锁释放操作 - if (tryRelease(arg)) { //可以看到这里调用了tryRelease方法,但是此方法并不是在AQS实现的,而是不同的锁自行实现,因为AQS也不知道你这种类型的锁到底该怎么去解锁 - Node h = head; - if (h != null && h.waitStatus != 0) - unparkSuccessor(h); - return true; - } - return false; -} - -protected boolean tryRelease(int arg) { - throw new UnsupportedOperationException(); //AQS中不支持,需要延迟到具体的子类去实现 -} -``` - -模板方法模式,实际上部分功能的实现是在子类完成的: - -```java -protected final boolean tryRelease(int releases) { - //ReentrantLock中的AQS Sync实现类,对tryRelease方法进行了具体实现 - int c = getState() - releases; - if (Thread.currentThread() != getExclusiveOwnerThread()) - throw new IllegalMonitorStateException(); - boolean free = false; - if (c == 0) { - free = true; - setExclusiveOwnerThread(null); - } - setState(c); - return free; -} -``` - -是不是现在感觉,这种层层套娃的写法,好像并不是这些大佬故意为了装逼才这样写的,而是真的在遵守规范编写,让代码更易懂一些,甚至你现在再回去推一遍会发现思路非常清晰。当然,除了这里之外,还有很多框架都使用了模板方法模式来设计类结构,还请各位小伙伴自行探索。 - -## 责任链模式 - -责任链模式也非常好理解,比如我们的钉钉审批,实际上就是一条流水线一样的操作,由你发起申请,然后经过多个部门主管审批,最后才能通过,所以你的申请表相当于是在一条责任链上传递。当然除了这样的直线型责任链之外,还有环形、树形等。 - -![image-20220524172400365](https://tva1.sinaimg.cn/large/e6c9d24ely1h2jmca3j84j21bs08kglw.jpg) - -实际上我们之前也遇到过很多种责任链,比如JavaWeb中学习的Filter过滤器,正是采用的责任链模式,通过将请求一级一级不断向下传递,来对我们所需要的请求进行过滤和处理。 - -![image-20220524231849889](https://tva1.sinaimg.cn/large/e6c9d24egy1h2jwlifbqfj21ci05ujrx.jpg) - -这里我们就使用责任链模式来模拟一个简单的面试过程,我们面试也是一面二面三面这样走的流程,这里我们先设计一下责任链上的各个处理器: - -```java -public abstract class Handler { - - protected Handler successor; //这里我们就设计责任链以单链表形式存在,这里存放后继节点 - - public Handler connect(Handler successor){ //拼接后续节点 - this.successor = successor; - return successor; //这里返回后继节点,方便我们一会链式调用 - } - - public void handle(){ - this.doHandle(); //由不同的子类实现具体处理过程 - Optional - .ofNullable(successor) - .ifPresent(Handler::handle); //责任链上如果还有后继节点,就继续向下传递 - } - - public abstract void doHandle(); //结合上节课学习的模板方法,交给子类实现 -} -``` - -因为面试有很多轮,所以我们这里创建几个处理器的实现: - -```java -public class FirstHandler extends Handler{ //用于一面的处理器 - @Override - public void doHandle() { - System.out.println("============= 白马程序员一面 =========="); - System.out.println("1. 谈谈你对static关键字的理解?"); - System.out.println("2. 内部类可以调用外部的数据吗?如果是静态的呢?"); - System.out.println("3. hashCode()方法是所有的类都有吗?默认返回的是什么呢?"); - System.out.println("以上问题会的,可以依次打在评论区"); - } -} -``` - -```java -public class SecondHandler extends Handler{ //二面 - @Override - public void doHandle() { - System.out.println("============= 白马程序员二面 =========="); - System.out.println("1. 如果我们自己创建一个java.lang包并且编写一个String类,能否实现覆盖JDK默认的?"); - System.out.println("2. HashMap的负载因子有什么作用?变化规律是什么?"); - System.out.println("3. 线程池的运作机制是什么?"); - System.out.println("4. ReentrantLock公平锁和非公平锁的区别是什么?"); - System.out.println("以上问题会的,可以依次打在评论区"); - } -} -``` - -```java -public class ThirdHandler extends Handler{ - @Override - public void doHandle() { - System.out.println("============= 白马程序员三面 =========="); - System.out.println("1. synchronized关键字了解吗?如何使用?底层是如何实现的?"); - System.out.println("2. IO和NIO的区别在哪里?NIO三大核心组件?"); - System.out.println("3. TCP握手和挥手流程?少一次握手可以吗?为什么?"); - System.out.println("4. 操作系统中PCB是做什么的?运行机制是什么?"); - System.out.println("以上问题会的,可以依次打在评论区"); - } -} -``` - -这样我们就编写好了每一轮的面试流程,现在我们就可以构建一个责任链了: - -```java -public static void main(String[] args) { - Handler handler = new FirstHandler(); //一面首当其冲 - handler - .connect(new SecondHandler()) //继续连接二面和三面 - .connect(new ThirdHandler()); - handler.handle(); //开始面试 -} -``` - -可以看到最后结果也是按照我们的责任链来进行的。 - -## 命令模式 - -大家有没有发现现在的家电都在趋向于智能化,通过一个中央控制器,我们就可以对家里的很多电器进行控制,比如国内做的比较好的小米智能家居系列,还有Apple的HomeKit等,我们只需要在一个终端上进行操作,就可以随便控制家里的电器。 - -![image-20220524235450650](https://tva1.sinaimg.cn/large/e6c9d24egy1h2jxmy55wej21g60jsjuz.jpg) - -比如现在我们有很多的类,彩电、冰箱、空调、洗衣机、热水器等,既然现在我们要通过一个遥控器去控制他们,那么我们就需要将控制这些电器的指令都给设计好才行,并且还不能有太强的关联性。 - -所有的电器肯定需要通过蓝牙或是红外线接受遥控器发送的请求,所以所有的电器都是接收者: - -```java -public interface Receiver { - void action(); //具体行为,这里就写一个算了 -} -``` - -接着我们要控制这些电器,那么肯定需要一个指令才能控制: - -```java -public abstract class Command { //指令抽象,不同的电器有指令 - - private final Receiver receiver; - - protected Command(Receiver receiver){ //指定此命令对应的电器(接受者) - this.receiver = receiver; - } - - public void execute() { - receiver.action(); //执行命令,实际上就是让接收者开始干活 - } -} -``` - -最后我们来安排一个遥控器: - -```java -public class Controller { //遥控器只需要把我们的指令发出去就行了 - public static void call(Command command){ - command.execute(); - } -} -``` - -比如现在我们创建一个空调,那么它就是作为我们命令的接收者: - -```java -public class AirConditioner implements Receiver{ - @Override - public void action() { - System.out.println("空调已开启,呼呼呼"); - } -} -``` - -现在我们创建一个开启空调的命令: - -```java -public class OpenCommand extends Command { - public OpenCommand(AirConditioner airConditioner) { - super(airConditioner); - } -} -``` - -最后我们只需要通过遥控器发送出去就可以了: - -```java -public static void main(String[] args) { - AirConditioner airConditioner = new AirConditioner(); //先创建一个空调 - Controller.call(new OpenCommand(airConditioner)); //直接通过遥控器来发送空调开启命令 -} -``` - -通过这种方式,遥控器这个角色并不需要知道具体会执行什么,只需要发送命令即可,遥控器和电器的关联性就不再那么强了。 - -## 迭代器模式 - -迭代器我们在JavaSE篇就已经讲解过了,迭代器可以说是我们学习Java语言的基础,没有迭代器,集合类的遍历就成了问题,正是因为有迭代器的存在,我们才能更加优雅的使用foreach语法。 - -回顾我们之前使用迭代器的场景: - -```java -public static void main(String[] args) { - List list = Arrays.asList("AAA", "BBB", "CCC"); - for (String s : list) { //使用foreach语法糖进行迭代,依次获取每一个元素 - System.out.println(s); //打印一下 - } -} -``` - -编译之后的代码如下: - -```java -public static void main(String[] args) { - List list = Arrays.asList("AAA", "BBB", "CCC"); - Iterator var2 = list.iterator(); //实际上这里本质是通过List生成的迭代器来遍历我们每个元素的 - - while(var2.hasNext()) { //判断是否还有元素可以迭代,没有就false - String s = (String)var2.next(); //通过next方法得到下一个元素,每调用一次,迭代器会向后移动一位 - System.out.println(s); //打印一下 - } -} -``` - -可以看到,当我们使用迭代器对List进行遍历时,实际上就像一个指向列表头部的指针,我们通过不断向后移动指针来依次获取所指向的元素: - -![image-20220525171535024](https://tva1.sinaimg.cn/large/e6c9d24ely1h2krpu5p8gj218m0800tp.jpg) - -![image-20220525171557523](https://tva1.sinaimg.cn/large/e6c9d24ely1h2krq82x3kj21c407474t.jpg) - -这里,我们依照JDK提供的迭代器接口(JDK已经为我们定义好了一个迭代器的具体相关操作),也来设计一个迭代器: - -```java -public class ArrayCollection { //首先设计一个简单的数组集合,一会我们就迭代此集合内的元素 - - private final T[] array; //底层使用一个数组来存放数据 - - private ArrayCollection(T[] array){ //private掉,自己用 - this.array = array; - } - - public static ArrayCollection of(T[] array){ //开个静态方法直接吧数组转换成ArrayCollection,其实和直接new一样,但是这样写好看一点 - return new ArrayCollection<>(array); - } -} -``` - -现在我们就可以将数据存放在此集合中了: - -```java -public static void main(String[] args) { - String[] arr = new String[]{"AAA", "BBB", "CCC", "DDD"}; - ArrayCollection collection = ArrayCollection.of(arr); -} -``` - -接着我们就可以来实现迭代器接口了: - -```java -public class ArrayCollection implements Iterable{ //实现Iterable接口表示此类是支持迭代的 - - ... - - @Override - public Iterator iterator() { //需要实现iterator方法,此方法会返回一个迭代器,用于迭代我们集合中的元素 - return new ArrayIterator(); - } - - public class ArrayIterator implements Iterator { //这里实现一个,注意别用静态,需要使用对象中存放的数组 - private int cur = 0; //这里我们通过一个指针表示当前的迭代位置 - - @Override - public boolean hasNext() { //判断是否还有下一个元素 - return cur < array.length; //如果指针大于或等于数组最大长度,就不能再继续了 - } - - @Override - public T next() { //返回当前指针位置的元素并向后移动一位 - return array[cur++]; //正常返回对应位置的元素,并将指针自增 - } - } -} -``` - -接着,我们就可以对我们自己编写的一个简单集合类进行迭代了: - -```java -public static void main(String[] args) { - String[] arr = new String[]{"AAA", "BBB", "CCC", "DDD"}; - ArrayCollection collection = ArrayCollection.of(arr); - for (String s : collection) { //可以直接使用foreach语法糖,当然最后还是会变成迭代器调用 - System.out.println(s); - } -} -``` - -最后编译出来的样子: - -```java -public static void main(String[] args) { - String[] arr = new String[]{"AAA", "BBB", "CCC", "DDD"}; - ArrayCollection collection = ArrayCollection.of(arr); - Iterator var3 = collection.iterator(); //首先获取迭代器,实际上就是调用我们实现的iterator方法 - - while(var3.hasNext()) { - String s = (String)var3.next(); //直接使用next()方法不断向下获取 - System.out.println(s); - } -} -``` - -这样我们就实现了一个迭代器来遍历我们的元素。 - -## 中介者模式 - -在早期,我们想要和别人进行语音聊天,一般都是通过电话的方式,我们通过拨打他人的电话号码,来建立会话,不过这样有一个问题,比如我现在想要通知通知3个人某件事情,那么我就得依次给三个人打电话,甚至还会遇到一种情况,就是我们没有某个人的电话号码,但是其他人有,这时还需要告知这个人并进行转告,就很麻烦。 - -![image-20220526164233041](https://tva1.sinaimg.cn/large/e6c9d24egy1h2lwdrfpkkj21dy0ewjx9.jpg) - -但是现在我们有了Facetime、有了微信,我们可以同时让多个人参与到群通话中进行群聊,这样我们就不需要一个一个单独进行通话或是转达了。实际上正是依靠了一个中间商给我们提供了进行群体通话的平台,我们才能实现此功能,而这个平台实际上就是一个中间人。又比如我们想要去外面租房,但是我们怎么知道哪里有可以租的房子呢?于是我们就会上各大租房APP上去找房源,同样的,如果我们现在有房子需要出租,我们也不知道谁会想要租房子,同样的我们也会把房子挂在租房APP上展示,而当我们去租房时或是出租时,就会有一个称为中介的人来跟我们对接,实际上也是一种中介的模式。 - -在我们的程序中,可能也会出现很多的对象,但是这些对象之间的相互调用关系错综复杂,可能一个对象要做什么事情就得联系好几个对象: - -![image-20220526174017796](https://tva1.sinaimg.cn/large/e6c9d24egy1h2ly1umglnj21fa0dswg2.jpg) - -但是如果我们在这中间搞一个中间人: - -![image-20220526174129303](https://tva1.sinaimg.cn/large/e6c9d24egy1h2ly3337mpj21ek0d4abk.jpg) - -这样当我们要联系其他人时,一律找中介就可以了,中介存储了所有人的联系方式,这样就不会像上面一样乱成一团了。这里我们就以房产中介的例子来编写: - -```java -public class Mediator { //房产中介 - private final Map userMap = new HashMap<>(); //在出售的房子需要存储一下 - - public void register(String address, User user){ //出售房屋的人,需要告诉中介他的房屋在哪里 - userMap.put(address, user); - } - - public User find(String address){ //通过此方法来看看有没有对应的房源 - return userMap.get(address); - } -} -``` - -接着就是用户了,用户有两种角色,一种是租房,一种是出租: - -```java -public class User { //用户可以是出售房屋的一方,也可以是寻找房屋的一方 - String name; - String tel; - - public User(String name, String tel) { - this.name = name; - this.tel = tel; - } - - public User find(String address, Mediator mediator){ //找房子的话,需要一个中介和你具体想找的地方 - return mediator.find(address); - } - - @Override - public String toString() { - return name+" (电话:"+tel+")"; - } -} -``` - -现在我们来测试一下: - -```java -public static void main(String[] args) { - User user0 = new User("刘女士", "10086"); //出租人 - User user1 = new User("李先生", "10010"); //找房人 - Mediator mediator = new Mediator(); //我是黑心中介 - - mediator.register("成都市武侯区天府五街白马程序员", user0); //先把房子给中介挂上去 - - User user = user1.find("成都市武侯区天府五街下硅谷", mediator); //开始找房子 - if(user == null) System.out.println("没有找到对应的房源"); - - user = user1.find("成都市武侯区天府五街白马程序员", mediator); //开始找房子 - System.out.println(user); //成功找到对应房源 -} -``` - -中介者模式优化了原有的复杂多对多关系,而是将其简化为一对多的关系,更容易理解一些。 - -## 备忘录模式 - -> 2021年10月1日下午,河南驻马店的一名13岁女中学生,因和同学发生不愉快喝下半瓶百草枯。 -> -> 10月5日,抢救四天情况恶化,家属泣不成声称“肺部一个小时一变”。 -> -> 10月6日下午,据武警河南省总队医院消息,“目前女孩仍在医院救治。” - -喝下百草枯,会给你后悔的时间,但是不会给你后悔的机会(百草枯含有剧毒物质,会直接导致肺部纤维化,这是不可逆的,一般死亡过程在一周左右,即使家里花了再多的钱,接受了再多的治疗,也无法逆转这一过程)相信如果再给这位小女孩一次机会,回到拿起百草枯的那一刻,一定不会再冲动地喝下了吧。 - -![image-20220527123444026](https://tva1.sinaimg.cn/large/e6c9d24egy1h2muu9cw13j20pq07y0u1.jpg) - -备忘录模式,就为我们的软件提供了一个可回溯的时间节点,可能我们程序在运行过程中某一步出现了错误,这时我们就可以回到之前的某个被保存的节点上重新来过(就像艾克的大招),我们平时编辑文本的时候,当我们编辑出现错误时,就需要撤回,而我们只需要按下`Ctrl+Z`就可以回到上一步,这样就大大方便了我们的文本编辑。 - -其实备忘录模式也可以应用到我们的程序中,如果你学习过安卓开发,安卓程序在很多情况下都会重新加载`Activity`,实际上安卓中`Activity`的`onSaveInstanceState`和`onRestoreInstanceState`就是用到了备忘录模式,分别用于保存和恢复,这样就算重新加载也可以恢复到之前的状态。 - -这里我们就模拟一下对象的状态保存: - -```java -public class Student { - private String currentWork; //当前正在做的事情 - private int percentage; //当前的工作完成百分比 - - public void work(String currentWork) { - this.currentWork = currentWork; - this.percentage = new Random().nextInt(100); - } - - @Override - public String toString() { - return "我现在正在做:"+currentWork+" (进度:"+percentage+"%)"; - } -} -``` - -接着我们需要保存它在某一时刻的状态,我们来编写一个状态保存类: - -```java -public class State { - final String currentWork; - final int percentage; - - State(String currentWork, int percentage) { //仅开放给同一个包下的Student类使用 - this.currentWork = currentWork; - this.percentage = percentage; - } -} -``` - -接着我们来将状态的保存和恢复操作都实现一下: - -```java -public class Student { - ... - - public State save(){ - return new State(currentWork, percentage); - } - - public void restore(State state){ - this.currentWork = state.currentWork; - this.percentage = state.percentage; - } - - ... -} -``` - -现在我们来测试一下吧: - -```java -public static void main(String[] args) { - Student student = new Student(); - student.work("学Java"); //开始学Java - System.out.println(student); - - State savedState = student.save(); //保存一下当前的状态 - - student.work("打电动"); //刚打开B站播放视频,学一半开始摆烂了 - System.out.println(student); - - student.restore(savedState); //两级反转!回到上一个保存的状态 - System.out.println(student); //回到学Java的状态 -} -``` - -可以看到,虽然在学习Java的过程中,中途摆烂了,但是我们可以时光倒流,回到还没开始摆烂的时候,继续学习Java: - -![image-20220527163947219](https://tva1.sinaimg.cn/large/e6c9d24egy1h2n1x785gtj20x803m74l.jpg) - -不过备忘录模式为了去保存对象的状态,会占用大量的资源,尤其是那种属性很多的对象,我们需要合理的使用才能保证程序稳定运行。 - -## 观察者模式 - -牵一发而动全身,一幅有序摆放的多米诺骨牌,在我们推到第一个骨牌时,后面的骨牌会不断地被上一个骨牌推倒: - -![image-20220527164444210](https://tva1.sinaimg.cn/large/e6c9d24egy1h2n22ctg4rj20ym0cgact.jpg) - -在Java中,一个对象的状态发生改变,可能就会影响到其他的对象,与之相关的对象可能也会联动的进行改变。还有我们之前遇到过的监听器机制,当具体的事件触发时,我们在一开始创建的监听器就可以执行相关的逻辑。我们可以使用观察者模式来实现这样的功能,当对象发生改变时,观察者能够立即观察到并进行一些联动操作,我们先定义一个观察者接口: - -```java -public interface Observer { //观察者接口 - void update(); //当对象有更新时,会回调此方法 -} -``` - -接着我们来写一个支持观察者的实体类: - -```java -public class Subject { - private final Set observerSet = new HashSet<>(); - - public void observe(Observer observer) { //添加观察者 - observerSet.add(observer); - } - - public void modify() { //模拟对象进行修改 - observerSet.forEach(Observer::update); //当对象发生修改时,会通知所有的观察者,并进行方法回调 - } -} -``` - -接着我们就可以测试一下了: - -```java -public static void main(String[] args) { - Subject subject = new Subject(); - subject.observe(() -> System.out.println("我是一号观察者!")); - subject.observe(() -> System.out.println("我是二号观察者!")); - subject.modify(); -} -``` - -这样,我们就简单实现了一下观察者模式,当然JDK也为我们提供了实现观察者模式相关的接口: - -```java -import java.util.Observable; //java.util包下提供的观察者抽象类 - -public class Subject extends Observable { //继承此抽象类表示支持观察者 - - public void modify(){ - System.out.println("对对象进行修改!"); - this.setChanged(); //当对对象修改后,需要setChanged来设定为已修改状态 - this.notifyObservers(new Date()); //使用notifyObservers方法来通知所有的观察者 - //注意只有已修改状态下通知观察者才会有效,并且可以给观察者传递参数,这里传递了一个时间对象 - } -} -``` - -我们来测试一下吧: - -```java -public static void main(String[] args) { - Subject subject = new Subject(); - subject.addObserver((o, arg) -> System.out.println("监听到变化,并得到参数:"+arg)); - //注意这里的Observer是java.util包下提供的 - subject.modify(); //进行修改操作 -} -``` - -## 状态模式 - -在标准大气压下,水在0度时会结冰变成固态,在0-100度之间时,会呈现液态,100度以上会变成气态,水这种物质在不同的温度下呈现出不同的状态,而我们的对象,可能也会像这样存在很多种状态,甚至在不同的状态下会有不同的行为,我们就可以通过状态模式来实现。 - -![image-20220527215236172](https://tva1.sinaimg.cn/large/e6c9d24egy1h2nayq0qf7j21780comxv.jpg) - -我们来设计一个学生类,然后学生的学习方法会根据状态不同而发生改变,我们先设计一个状态枚举: - -```java -public enum State { //状态直接使用枚举定义 - NORMAL, LAZY -} -``` - -接着我们来编写一个学生类: - -```java -public class Student { - - public class Student { - - private State state; //使用一个成员来存储状态 - - public void setState(State state) { - this.state = state; - } - - public void study(){ - switch (state) { //根据不同的状态,学习方法会有不同的结果 - case LAZY: - System.out.println("只要我不努力,老板就别想过上想要的生活,开摆!"); - break; - case NORMAL: - System.out.println("拼搏百天,我要上清华大学!"); - break; - } - } -} -``` - -我们来看看,在不同的状态下,是否学习会出现不同的效果: - -```java -public static void main(String[] args) { - Student student = new Student(); - student.setState(State.NORMAL); //先正常模式 - student.study(); - - student.setState(State.LAZY); //开启摆烂模式 - student.study(); -} -``` - -状态模式更加强调当前的对象所处的状态,我们需要根据对象不同的状态决定其他的处理逻辑。 - -## 策略模式 - -对面卡兹克打野被开了,我们是去打小龙还是打大龙呢?这就要看我们团队这一局的打法策略了。 - -![image-20220527222552772](https://tva1.sinaimg.cn/large/e6c9d24egy1h2nbxb021mj20ww0aotb1.jpg) - -我们可以为对象设定一种策略,这样对象之后的行为就会按照我们在一开始指定的策略而决定了,看起来和前面的状态模式很像,但是,它与状态模式的区别在于,这种转换是“主动”的,是由我们去指定,而状态模式,可能是在运行过程中自动切换的。 - -其实策略模式我们之前也遇到过,比如线程池的拒绝策略: - -```java -public static void main(String[] args) { - ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 10, - TimeUnit.SECONDS, new SynchronousQueue<>(), //这里不给排队 - new ThreadPoolExecutor.AbortPolicy()); //当线程池无法再继续创建新任务时,我们可以自由决定使用什么拒绝策略 - - Runnable runnable = () -> { - try { - TimeUnit.SECONDS.sleep(60); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - }; - - executor.execute(runnable); //连续提交两次任务,肯定塞不下,这时就得走拒绝了 - executor.execute(runnable); -} -``` - -可以看到,我们如果使用AbortPolicy,那么就是直接抛出异常: - -![image-20220527223231753](https://tva1.sinaimg.cn/large/e6c9d24egy1h2nc480yhjj21ga034aap.jpg) - -我们也可以使用其他的策略: - -```java -public static void main(String[] args) { - ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 10, - TimeUnit.SECONDS, new SynchronousQueue<>(), - new ThreadPoolExecutor.DiscardOldestPolicy()); //使用DiscardOldestPolicy策略从队列中丢弃 -``` - -这种策略就会从等待队列中踢出一个之前的,不过我们这里的等待队列是没有容量的那种,所以会直接炸掉: - -![image-20220527223510016](https://tva1.sinaimg.cn/large/e6c9d24egy1h2nc6ypzsnj21200220sw.jpg) - -至于具体原因,可以回去看看JUC篇视频教程。 - -再比如我们现在有一个排序类,但是根据不同的策略,会使用不同的排序方案: - -```java -public interface Strategy { //策略接口,不同的策略实现也不同 - - Strategy SINGLE = Arrays::sort; //单线程排序方案 - Strategy PARALLEL = Arrays::parallelSort; //并行排序方案 - - void sort(int[] array); -} -``` - -现在我们编写一个排序类: - -```java -public class Sorter { - - private Strategy strategy; //策略 - - public void setStrategy(Strategy strategy) { - this.strategy = strategy; - } - - public void sort(int[] array){ - strategy.sort(array); - } -} -``` - -现在我们就可以指定不同的策略进行排序了: - -```java -public static void main(String[] args) { - Sorter sorter = new Sorter(); - sorter.setStrategy(Strategy.PARALLEL); //指定为并行排序方案 - - sorter.sort(new int[]{9, 2, 4, 5, 1, 0, 3, 7}); -} -``` - -## 访问者模式 - -公园中存在多个景点,也存在多个游客,不同的游客对同一个景点的评价可能不同;医院医生开的处方单中包含多种药元素,査看它的划价员和药房工作人员对它的处理方式也不同,划价员根据处方单上面的药品名和数量进行划价,药房工作人员根据处方单的内容进行抓药,相对于处方单来说,划价员和药房工作人员就是它的访问者,不过访问者的访问方式可能会不同。 - -![image-20220527231226552](https://tva1.sinaimg.cn/large/e6c9d24egy1h2nd9r7qzlj219u0dk432.jpg) - -在我们的Java程序中,也可能会出现这种情况,我们就可以通过访问者模式来进行设计。 - -比如我们日以继夜地努力,终于在某某比赛赢得了冠军,而不同的人对于这分荣誉,却有着不同的反应: - -```java -public class Prize { //奖 - String name; //比赛名称 - String level; //等级 - - public Prize(String name, String level) { - this.name = name; - this.level = level; - } - - public String getName() { - return name; - } - - public String getLevel() { - return level; - } -} -``` - -我们首先定义一个访问者接口: - -```java -public interface Visitor { - void visit(Prize prize); //visit方法来访问我们的奖项 -} -``` - -然后就是访问者相关的实现了: - -```java -public class Teacher implements Visitor { //指导老师作为一个访问者 - @Override - public void visit(Prize prize) { //它只关心你得了什么奖以及是几等奖,这也关乎老师的荣誉 - System.out.println("你得奖是什么奖?"+prize.name); - System.out.println("你得了几等奖?"+prize.level); - } -} -``` - -```java -public class Boss implements Visitor{ //你的公司老板作为一个访问者 - @Override - public void visit(Prize prize) { //你的老板只关心这些能不能为公司带来什么效益,奖本身并不重要 - System.out.println("你的奖项大么,能够为公司带来什么效益么?"); - System.out.println("还不如老老实实加班给我多干干,别去搞这些没用的"); - } -} -``` - -```java -public class Classmate implements Visitor{ //你的同学也可以作为一个访问者 - @Override - public void visit(Prize prize) { //你的同学也关心你得了什么奖,不过是因为你是他的奖学金竞争对手,他其实并不希望你得奖 - System.out.println("你得了"+prize.name+"奖啊,还可以"); - System.out.println("不过这个奖没什么含金量,下次别去了"); - } -} -``` - -```java -public class Family implements Visitor{ //你的家人也可以是一个访问者 - @Override - public void visit(Prize prize) { //你的家人并不是最关心你得了什么奖,而是先关心你自己然后才是奖项,他们才是真正希望你好的人。这个世界很残酷,可能你会被欺负得遍体鳞伤,可能你会觉得活着如此艰难,但是你的背后至少还有爱你的人,为了他们,怎能就此驻足。 - System.out.println("孩子,辛苦了,有没有好好照顾自己啊"); - System.out.println("你得了什么奖啊?"+prize.name+",很不错,要继续加油啊!"); - } -} -``` - -可以看到,这里我们就设计了四种访问者,但是不同的访问者对于某一件事务的处理可能会不同。访问者模式把数据结构和作用于结构上的操作解耦,使得操作集合可相对自由地演化,我们上面就是将奖项本身的属性和对于奖项的不同操作进行了分离。 diff --git a/青空笔记/NIO笔记/Java NIO笔记(一).md b/青空笔记/NIO笔记/Java NIO笔记(一).md deleted file mode 100644 index bb1907f..0000000 --- a/青空笔记/NIO笔记/Java NIO笔记(一).md +++ /dev/null @@ -1,2276 +0,0 @@ -![image-20220422233930778](https://tva1.sinaimg.cn/large/e6c9d24egy1h1ixd6z9kxj21hi0dmab5.jpg) - -# NIO基础 - -**注意:**推荐完成JavaSE篇、JavaWeb篇的学习再开启这一部分的学习,如果在这之前完成了JVM篇,那么看起来就会比较轻松了。 - -在JavaSE的学习中,我们了解了如何使用IO进行数据传输,Java IO是阻塞的,如果在一次读写数据调用时数据还没有准备好,或者目前不可写,那么读写操作就会被阻塞直到数据准备好或目标可写为止。Java NIO则是非阻塞的,每一次数据读写调用都会立即返回,并将目前可读(或可写)的内容写入缓冲区或者从缓冲区中输出,即使当前没有可用数据,调用仍然会立即返回并且不对缓冲区做任何操作。 - -NIO框架是在JDK1.4推出的,它的出现就是为了解决传统IO的不足,这一期视频,我们就将围绕着NIO开始讲解。 - -## 缓冲区 - -一切的一切还要从缓冲区开始讲起,包括源码在内,其实这个不是很难,只是需要理清思路。 - -### Buffer类及其实现 - -Buffer类是缓冲区的实现,类似于Java中的数组,也是用于存放和获取数据的。但是Buffer相比Java中的数组,功能就非常强大了,它包含一系列对于数组的快捷操作。 - -Buffer是一个抽象类,它的核心内容: - -```java -public abstract class Buffer { - // 这四个变量的关系: mark <= position <= limit <= capacity - // 这些变量就是Buffer操作的核心了,之后我们学习的过程中可以看源码是如何操作这些变量的 - private int mark = -1; - private int position = 0; - private int limit; - private int capacity; - - // 直接缓冲区实现子类的数据内存地址(之后会讲解) - long address; -``` - -我们来看看Buffer类的子类,包括我们认识到的所有基本类型(除了`boolean`类型之外): - -* IntBuffer - int类型的缓冲区。 -* ShortBuffer - short类型的缓冲区。 -* LongBuffer - long类型的缓冲区。 -* FloatBuffer - float类型的缓冲区。 -* DoubleBuffer - double类型的缓冲区。 -* ByteBuffer - byte类型的缓冲区。 -* CharBuffer - char类型的缓冲区。 - -(注意我们之前在JavaSE中学习过的StringBuffer虽然也是这种命名方式,但是不属于Buffer体系,这里不会进行介绍) - -这里我们以IntBuffer为例,我们来看看如何创建一个Buffer类: - -```java -public static void main(String[] args) { - //创建一个缓冲区不能直接new,而是需要使用静态方法去生成,有两种方式: - //1. 申请一个容量为10的int缓冲区 - IntBuffer buffer = IntBuffer.allocate(10); - //2. 可以将现有的数组直接转换为缓冲区(包括数组中的数据) - int[] arr = new int[]{1, 2, 3, 4, 5, 6}; - IntBuffer buffer = IntBuffer.wrap(arr); -} -``` - -那么它的内部是本质上如何进行操作的呢?我们来看看它的源码: - -```java -public static IntBuffer allocate(int capacity) { - if (capacity < 0) //如果申请的容量小于0,那还有啥意思 - throw new IllegalArgumentException(); - return new HeapIntBuffer(capacity, capacity); //可以看到这里会直接创建一个新的IntBuffer实现类 - //HeapIntBuffer是在堆内存中存放数据,本质上就数组,一会我们可以在深入看一下 -} -``` - -```java -public static IntBuffer wrap(int[] array, int offset, int length) { - try { - //可以看到这个也是创建了一个新的HeapIntBuffer对象,并且给了初始数组以及截取的起始位置和长度 - return new HeapIntBuffer(array, offset, length); - } catch (IllegalArgumentException x) { - throw new IndexOutOfBoundsException(); - } -} - -public static IntBuffer wrap(int[] array) { - return wrap(array, 0, array.length); //调用的是上面的wrap方法 -} -``` - -那么这个HeapIntBuffer又是如何实现的呢,我们接着来看: - -```java -HeapIntBuffer(int[] buf, int off, int len) { // 注意这个构造方法不是public,是默认的访问权限 - super(-1, off, off + len, buf.length, buf, 0); //你会发现这怎么又去调父类的构造方法了,绕来绕去 - //mark是标记,off是当前起始下标位置,off+len是最大下标位置,buf.length是底层维护的数组真正长度,buf就是数组,最后一个0是起始偏移位置 -} -``` - -我们又来看看IntBuffer中的构造方法是如何定义的: - -```java -final int[] hb; // 只有在堆缓冲区实现时才会使用 -final int offset; -boolean isReadOnly; // 只有在堆缓冲区实现时才会使用 - -IntBuffer(int mark, int pos, int lim, int cap, // 注意这个构造方法不是public,是默认的访问权限 - int[] hb, int offset) -{ - super(mark, pos, lim, cap); //调用Buffer类的构造方法 - this.hb = hb; //hb就是真正我们要存放数据的数组,堆缓冲区底层其实就是这么一个数组 - this.offset = offset; //起始偏移位置 -} -``` - -最后我们来看看Buffer中的构造方法: - -```java -Buffer(int mark, int pos, int lim, int cap) { // 注意这个构造方法不是public,是默认的访问权限 - if (cap < 0) //容量不能小于0,小于0还玩个锤子 - throw new IllegalArgumentException("Negative capacity: " + cap); - this.capacity = cap; //设定缓冲区容量 - limit(lim); //设定最大position位置 - position(pos); //设定起始位置 - if (mark >= 0) { //如果起始标记大于等于0 - if (mark > pos) //并且标记位置大于起始位置,那么就抛异常(至于为啥不能大于我们后面再说) - throw new IllegalArgumentException("mark > position: (" - + mark + " > " + pos + ")"); - this.mark = mark; //否则设定mark位置(mark默认为-1) - } -} -``` - -通过对源码的观察,我们大致可以得到以下结构了: - -![image-20220424093805677](https://tva1.sinaimg.cn/large/e6c9d24ely1h1kkaatg47j21kk0bgmyp.jpg) - -现在我们来总结一下上面这些结构的各自职责划分: - -* Buffer:缓冲区的一些基本变量定义,比如当前的位置(position)、容量 (capacity)、最大限制 (limit)、标记 (mark)等,你肯定会疑惑这些变量有啥用,别着急,这些变量会在后面的操作中用到,我们逐步讲解。 -* IntBuffer等子类:定义了存放数据的数组(只有堆缓冲区实现子类才会用到)、是否只读等,也就是说数据的存放位置、以及对于底层数组的相关操作都在这里已经定义好了,并且已经实现了Comparable接口。 -* HeapIntBuffer堆缓冲区实现子类:数据存放在堆中,实际上就是用的父类的数组在保存数据,并且将父类定义的所有底层操作全部实现了。 - -这样,我们对于Buffer类的基本结构就有了一个大致的认识。 - -### 缓冲区写操作 - -前面我们了解了Buffer类的基本操作,现在我们来看一下如何向缓冲区中存放数据以及获取数据,数据的存放包括以下四个方法: - -* public abstract IntBuffer put(int i); - 在当前position位置插入数据,由具体子类实现 -* public abstract IntBuffer put(int index, int i); - 在指定位置存放数据,也是由具体子类实现 -* public final IntBuffer put(int[] src); - 直接存放所有数组中的内容(数组长度不能超出缓冲区大小) -* public IntBuffer put(int[] src, int offset, int length); - 直接存放数组中的内容,同上,但是可以指定存放一段范围 -* public IntBuffer put(IntBuffer src); - 直接存放另一个缓冲区中的内容 - -我们从最简的开始看,是在当前位置插入一个数据,那么这个当前位置是怎么定义的呢,我们来看看源码: - -```java -public IntBuffer put(int x) { - hb[ix(nextPutIndex())] = x; //这个ix和nextPutIndex()很灵性,我们来看看具体实现 - return this; -} - -protected int ix(int i) { - return i + offset; //将i的值加上我们之前设定的offset偏移量值,但是默认是0(非0的情况后面会介绍) -} - -final int nextPutIndex() { - int p = position; //获取Buffer类中的position位置(一开始也是0) - if (p >= limit) //位置肯定不能超过底层数组最大长度,否则越界 - throw new BufferOverflowException(); - position = p + 1; //获取之后会使得Buffer类中的position+1 - return p; //返回当前的位置 -} -``` - -所以put操作实际上是将底层数组`hb`在position位置上的数据进行设定。 - -![image-20220424113417640](https://tva1.sinaimg.cn/large/e6c9d24ely1h1knn61e76j21ra08it8y.jpg) - -设定完成后,position自动后移: - -![image-20220424113440765](https://tva1.sinaimg.cn/large/e6c9d24ely1h1knnkm8omj21ro08gjrs.jpg) - -我们可以编写代码来看看: - -```java -public static void main(String[] args) { - IntBuffer buffer = IntBuffer.allocate(10); - buffer - .put(1) - .put(2) - .put(3); //我们依次存放三个数据试试看 - System.out.println(buffer); -} -``` - -通过断点调试,我们来看看实际的操作情况: - -![image-20220424105031549](https://tva1.sinaimg.cn/large/e6c9d24ely1h1kmdmh8ypj21ks0b8gmx.jpg) - -可以看到我们不断地put操作,position会一直向后移动,当然如果超出最大长度,那么会直接抛出异常: - -![image-20220424105131279](https://tva1.sinaimg.cn/large/e6c9d24ely1h1kmenwakbj21lu04kwfk.jpg) - -接着我们来看看第二个put操作是如何进行,它能够在指定位置插入数据: - -```java -public IntBuffer put(int i, int x) { - hb[ix(checkIndex(i))] = x; //这里依然会使用ix,但是会检查位置是否合法 - return this; -} - -final int checkIndex(int i) { // package-private - if ((i < 0) || (i >= limit)) //插入的位置不能小于0并且不能大于等于底层数组最大长度 - throw new IndexOutOfBoundsException(); - return i; //没有问题就把i返回 -} -``` - -实际上这个比我们之前的要好理解一些,注意全程不会操作position的值,这里需要注意一下。 - -我们接着来看第三个put操作,它是直接在IntBuffer中实现的,是基于前两个put方法的子类实现来完成的: - -```java -public IntBuffer put(int[] src, int offset, int length) { - checkBounds(offset, length, src.length); //检查截取范围是否合法,给offset、调用者指定长度、数组实际长度 - if (length > remaining()) //接着判断要插入的数据量在缓冲区是否容得下,装不下也不行 - throw new BufferOverflowException(); - int end = offset + length; //计算出最终读取位置,下面开始for - for (int i = offset; i < end; i++) - this.put(src[i]); //注意是直接从postion位置开始插入,直到指定范围结束 - return this; //ojbk -} - -public final IntBuffer put(int[] src) { - return put(src, 0, src.length); //因为不需要指定范围,所以直接0和length,然后调上面的,多捞哦 -} - -public final int remaining() { //计算并获取当前缓冲区的剩余空间 - int rem = limit - position; //最大容量减去当前位置,就是剩余空间 - return rem > 0 ? rem : 0; //没容量就返回0 -} -``` - -```java -static void checkBounds(int off, int len, int size) { // package-private - if ((off | len | (off + len) | (size - (off + len))) < 0) //让我猜猜,看不懂了是吧 - throw new IndexOutOfBoundsException(); - //实际上就是看给定的数组能不能截取出指定的这段数据,如果都不够了那肯定不行啊 -} -``` - -大致流程如下,首先来了一个数组要取一段数据全部丢进缓冲区: - -![image-20220424113337189](https://tva1.sinaimg.cn/large/e6c9d24ely1h1knmgx4rfj21qy0j6wga.jpg) - -在检查没有什么问题并且缓冲区有容量时,就可以开始插入了: - -![Img](https://tva1.sinaimg.cn/large/e6c9d24ely1h1knm0wy6bj21rq0jaac6.jpg) - -最后我们通过代码来看看: - -```java -public static void main(String[] args) { - IntBuffer buffer = IntBuffer.allocate(10); - int[] arr = new int[]{1,2,3,4,5,6,7,8,9}; - buffer.put(arr, 3, 4); //从下标3开始,截取4个元素 - - System.out.println(Arrays.toString(buffer.array())); //array方法可以直接获取到数组 -} -``` - -可以看到最后结果为: - -![image-20220424113040485](https://tva1.sinaimg.cn/large/e6c9d24ely1h1knjeatbjj219y01kmx1.jpg) - -当然我们也可以将一个缓冲区的内容保存到另一个缓冲区: - -```java -public IntBuffer put(IntBuffer src) { - if (src == this) //不会吧不会吧,不会有人保存自己吧 - throw new IllegalArgumentException(); - if (isReadOnly()) //如果是只读的话,那么也是不允许插入操作的(我猜你们肯定会问为啥就这里会判断只读,前面四个呢) - throw new ReadOnlyBufferException(); - int n = src.remaining(); //给进来的src看看容量(注意这里不remaining的结果不是剩余容量,是转换后的,之后会说) - if (n > remaining()) //这里判断当前剩余容量是否小于src容量 - throw new BufferOverflowException(); - for (int i = 0; i < n; i++) //也是从position位置开始继续写入 - put(src.get()); //通过get方法一个一个读取数据出来,具体过程后面讲解 - return this; -} -``` - -我们来看看效果: - -```java -public static void main(String[] args) { - IntBuffer src = IntBuffer.wrap(new int[]{1, 2, 3, 4, 5}); - IntBuffer buffer = IntBuffer.allocate(10); - buffer.put(src); - System.out.println(Arrays.toString(buffer.array())); -} -``` - -但是如果是这样的话,会出现问题: - -```java -public static void main(String[] args) { - IntBuffer src = IntBuffer.allocate(5); - for (int i = 0; i < 5; i++) src.put(i); //手动插入数据 - IntBuffer buffer = IntBuffer.allocate(10); - buffer.put(src); - System.out.println(Arrays.toString(buffer.array())); -} -``` - -我们发现,结果和上面的不一样,并没有成功地将数据填到下面的IntBuffer中,这是为什么呢?实际上就是因为`remaining()`的计算问题,因为这个方法是直接计算postion的位置,但是由于我们在写操作完成之后,position跑到后面去了,也就导致`remaining()`结果最后算出来为0。 - -因为这里不是写操作,是接下来需要从头开始进行读操作,所以我们得想个办法把position给退回到一开始的位置,这样才可以从头开始读取,那么怎么做呢?一般我们在写入完成后需要进行读操作时(后面都是这样,不只是这里),会使用`flip()`方法进行翻转: - -```java -public final Buffer flip() { - limit = position; //修改limit值,当前写到哪里,下次读的最终位置就是这里,limit的作用开始慢慢体现了 - position = 0; //position归零 - mark = -1; //标记还原为-1,但是现在我们还没用到 - return this; -} -``` - -这样,再次计算`remaining()`的结果就是我们需要读取的数量了,这也是为什么put方法中要用`remaining()`来计算的原因,我们再来测试一下: - -```java -public static void main(String[] args) { - IntBuffer src = IntBuffer.allocate(5); - for (int i = 0; i < 5; i++) src.put(i); - IntBuffer buffer = IntBuffer.allocate(10); - - src.flip(); //我们可以通过flip来翻转缓冲区 - buffer.put(src); - System.out.println(Arrays.toString(buffer.array())); -} -``` - -翻转之后再次进行转移,就正常了。 - -### 缓冲区读操作 - -前面我们看完了写操作,现在我们接着来看看读操作。读操作有四个方法: - -* `public abstract int get();` - 直接获取当前position位置的数据,由子类实现 -* `public abstract int get(int index); ` - 获取指定位置的数据,也是子类实现 -* `public IntBuffer get(int[] dst)` - 将数据读取到给定的数组中 -* `public IntBuffer get(int[] dst, int offset, int length)` - 同上,加了个范围 - -我们还是从最简单的开始看,第一个get方法的实现在IntBuffer类中: - -```java -public int get() { - return hb[ix(nextGetIndex())]; //直接从数组中取就完事 -} - -final int nextGetIndex() { // 好家伙,这不跟前面那个一模一样吗 - int p = position; - if (p >= limit) - throw new BufferUnderflowException(); - position = p + 1; - return p; -} -``` - -可以看到每次读取操作之后,也会将postion+1,直到最后一个位置,如果还要继续读,那么就直接抛出异常。 - -![image-20220424123743020](https://tva1.sinaimg.cn/large/e6c9d24ely1h1kph5va26j21ry07ut94.jpg) - -我们来看看第二个: - -```java -public int get(int i) { - return hb[ix(checkIndex(i))]; //这里依然是使用checkIndex来检查位置是否非法 -} -``` - -我们来看看第三个和第四个: - -```java -public IntBuffer get(int[] dst, int offset, int length) { - checkBounds(offset, length, dst.length); //跟put操作一样,也是需要检查是否越界 - if (length > remaining()) //如果读取的长度比可以读的长度大,那肯定是不行的 - throw new BufferUnderflowException(); - int end = offset + length; //计算出最终读取位置 - for (int i = offset; i < end; i++) - dst[i] = get(); //开始从position把数据读到数组中,注意是在数组的offset位置开始 - return this; -} - -public IntBuffer get(int[] dst) { - return get(dst, 0, dst.length); //不指定范围的话,那就直接用上面的 -} -``` - -我们来看看效果: - -```java -public static void main(String[] args) { - IntBuffer buffer = IntBuffer.wrap(new int[]{1, 2, 3, 4, 5}); - int[] arr = new int[10]; - buffer.get(arr, 2, 5); - System.out.println(Arrays.toString(arr)); -} -``` - -![image-20220424125203822](https://tva1.sinaimg.cn/large/e6c9d24ely1h1kpw3b24fj21d601aq2t.jpg) - -可以看到成功地将数据读取到了数组中。 - -当然如果我们需要直接获取数组,也可以使用`array()`方法来拿到: - -```java -public final int[] array() { - if (hb == null) //为空那说明底层不是数组实现的,肯定就没法转换了 - throw new UnsupportedOperationException(); - if (isReadOnly) //只读也是不让直接取出的,因为一旦取出去岂不是就能被修改了 - throw new ReadOnlyBufferException(); - return hb; //直接返回hb -} -``` - -我们来试试看: - -```java -public static void main(String[] args) { - IntBuffer buffer = IntBuffer.wrap(new int[]{1, 2, 3, 4, 5}); - System.out.println(Arrays.toString(buffer.array())); -} -``` - -当然,既然都已经拿到了底层的`hb`了,我们来看看如果直接修改之后是不是读取到的就是我们的修改之后的结果了: - -```java -public static void main(String[] args) { - IntBuffer buffer = IntBuffer.wrap(new int[]{1, 2, 3, 4, 5}); - int[] arr = buffer.array(); - arr[0] = 99999; //拿到数组对象直接改 - System.out.println(buffer.get()); -} -``` - -可以看到这种方式由于是直接拿到的底层数组,所有修改会直接生效在缓冲区中。 - -当然除了常规的读取方式之外,我们也可以通过`mark()`来实现跳转读取,这里需要介绍一下几个操作: - -* `public final Buffer mark()` - 标记当前位置 -* `public final Buffer reset()` - 让当前的position位置跳转到mark当时标记的位置 - -我们首先来看标记方法: - -```java -public final Buffer mark() { - mark = position; //直接标记到当前位置,mark变量终于派上用场了,当然这里仅仅是标记 - return this; -} -``` - -我们再来看看重置方法: - -```java -public final Buffer reset() { - int m = mark; //存一下当前的mark位置 - if (m < 0) //因为mark默认是-1,要是没有进行过任何标记操作,那reset个毛 - throw new InvalidMarkException(); - position = m; //直接让position变成mark位置 - return this; -} -``` - -那比如我们在读取到1号位置时进行标记: - -![image-20220424135842228](https://tva1.sinaimg.cn/large/e6c9d24ely1h1krtfdjjxj21qw082t96.jpg) - -接着我们使用reset方法就可以直接回退回去了: - -![image-20220424135925501](https://tva1.sinaimg.cn/large/e6c9d24ely1h1kru5ys1aj21ru096dgg.jpg) - -现在我们来测试一下: - -```java -public static void main(String[] args) { - IntBuffer buffer = IntBuffer.wrap(new int[]{1, 2, 3, 4, 5}); - buffer.get(); //读取一位,那么position就变成1了 - buffer.mark(); //这时标记,那么mark = 1 - buffer.get(); //又读取一位,那么position就变成2了 - buffer.reset(); //直接将position = mark,也就是变回1 - System.out.println(buffer.get()); -} -``` - -可以看到,读取的位置根据我们的操作进行了变化,有关缓冲区的读操作,就暂时讲到这里。 - -### 缓冲区其他操作 - -前面我们大致了解了一下缓冲区的读写操作,那么我们接着来看看,除了常规的读写操作之外,还有哪些其他的操作: - -* `public abstract IntBuffer compact()` - 压缩缓冲区,由具体实现类实现 -* `public IntBuffer duplicate()` - 复制缓冲区,会直接创建一个新的数据相同的缓冲区 -* `public abstract IntBuffer slice()` - 划分缓冲区,会将原本的容量大小的缓冲区划分为更小的出来进行操作 -* `public final Buffer rewind()` - 重绕缓冲区,其实就是把position归零,然后mark变回-1 -* `public final Buffer clear()` - 将缓冲区清空,所有的变量变回最初的状态 - -我们先从压缩缓冲区开始看起,它会将整个缓冲区的大小和数据内容变成position位置到limit之间的数据,并移动到数组头部: - -```java -public IntBuffer compact() { - int pos = position(); //获取当前位置 - int lim = limit(); //获取当前最大position位置 - assert (pos <= lim); //断言表达式,position必须小于最大位置,肯定的 - int rem = (pos <= lim ? lim - pos : 0); //计算pos距离最大位置的长度 - System.arraycopy(hb, ix(pos), hb, ix(0), rem); //直接将hb数组当前position位置的数据拷贝到头部去,然后长度改成刚刚计算出来的空间 - position(rem); //直接将position移动到rem位置 - limit(capacity()); //pos最大位置修改为最大容量 - discardMark(); //mark变回-1 - return this; -} -``` - -比如现在的状态是: - -![image-20220424140040711](https://tva1.sinaimg.cn/large/e6c9d24ely1h1krvhots6j21s0088aar.jpg) - -那么我们在执行` compact()`方法之后,会进行截取,此时`limit - position = 6`,那么就会截取第`4、5、6、7、8、9`这6个数据然后丢到最前面,接着position跑到`7`表示这是下一个继续的位置: - -![image-20220424140326373](https://tva1.sinaimg.cn/large/e6c9d24ely1h1krycqrmej21ri080wfb.jpg) - -现在我们通过代码来检验一下: - -```java -public static void main(String[] args) { - IntBuffer buffer = IntBuffer.wrap(new int[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 0}); - for (int i = 0; i < 4; i++) buffer.get(); //先正常读4个 - buffer.compact(); //压缩缓冲区 - - System.out.println("压缩之后的情况:"+Arrays.toString(buffer.array())); - System.out.println("当前position位置:"+buffer.position()); - System.out.println("当前limit位置:"+buffer.limit()); -} -``` - -可以看到最后的结果没有问题: - -![image-20220424141916082](https://tva1.sinaimg.cn/large/e6c9d24ely1h1ksetaccij21b603oq35.jpg) - -我们接着来看第二个方法,那么如果我们现在需要复制一个内容一模一样的的缓冲区,该怎么做?直接使用`duplicate()`方法就可以复制了: - -```java -public IntBuffer duplicate() { //直接new一个新的,但是是吧hb给丢进去了,而不是拷贝一个新的 - return new HeapIntBuffer(hb, - this.markValue(), - this.position(), - this.limit(), - this.capacity(), - offset); -} -``` - -那么各位猜想一下,如果通过这种方式创了一个新的IntBuffer,那么下面的例子会出现什么结果: - -```java -public static void main(String[] args) { - IntBuffer buffer = IntBuffer.wrap(new int[]{1, 2, 3, 4, 5}); - IntBuffer duplicate = buffer.duplicate(); - - System.out.println(buffer == duplicate); - System.out.println(buffer.array() == duplicate.array()); -} -``` - -由于buffer是重新new的,所以第一个为false,而底层的数组由于在构造的时候没有进行任何的拷贝而是直接传递,因此实际上两个缓冲区的底层数组是同一个对象。所以,一个发生修改,那么另一个就跟着变了: - -```java -public static void main(String[] args) { - IntBuffer buffer = IntBuffer.wrap(new int[]{1, 2, 3, 4, 5}); - IntBuffer duplicate = buffer.duplicate(); - - buffer.put(0, 66666); - System.out.println(duplicate.get()); -} -``` - -现在我们接着来看下一个方法,`slice()`方法会将缓冲区进行划分: - -```java -public IntBuffer slice() { - int pos = this.position(); //获取当前position - int lim = this.limit(); //获取position最大位置 - int rem = (pos <= lim ? lim - pos : 0); //求得剩余空间 - return new HeapIntBuffer(hb, //返回一个新的划分出的缓冲区,但是底层的数组用的还是同一个 - -1, - 0, - rem, //新的容量变成了剩余空间的大小 - rem, - pos + offset); //可以看到offset的地址不再是0了,而是当前的position加上原有的offset值 -} -``` - -虽然现在底层依然使用的是之前的数组,但是由于设定了offset值,我们之前的操作似乎变得不太一样了: - -![image-20220424142642088](https://tva1.sinaimg.cn/large/e6c9d24ely1h1ksmjx76ij21ru07u75a.jpg) - -回顾前面我们所讲解的内容,在读取和存放时,会被`ix`方法进行调整: - -```java -protected int ix(int i) { - return i + offset; //现在offset为4,那么也就是说逻辑上的i是0但是得到真实位置却是4 -} - -public int get() { - return hb[ix(nextGetIndex())]; //最后会经过ix方法转换为真正在数组中的位置 -} -``` - -当然,在逻辑上我们可以认为是这样的: - -![image-20220424143002885](https://tva1.sinaimg.cn/large/e6c9d24ely1h1ksq30gzij21pw08gt9b.jpg) - -现在我们来测试一下: - -```java -public static void main(String[] args) { - IntBuffer buffer = IntBuffer.wrap(new int[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 0}); - for (int i = 0; i < 4; i++) buffer.get(); - IntBuffer slice = buffer.slice(); - - System.out.println("划分之后的情况:"+Arrays.toString(slice.array())); - System.out.println("划分之后的偏移地址:"+slice.arrayOffset()); - System.out.println("当前position位置:"+slice.position()); - System.out.println("当前limit位置:"+slice.limit()); - - while (slice.hasRemaining()) { //将所有的数据全部挨着打印出来 - System.out.print(slice.get()+", "); - } -} -``` - -可以看到,最终结果: - -![image-20220424143036449](https://tva1.sinaimg.cn/large/e6c9d24ely1h1ksqmk9smj21dw05q3yy.jpg) - -最后两个方法就比较简单了,我们先来看`rewind()`,它相当于是对position和mark进行了一次重置: - -```java -public final Buffer rewind() { - position = 0; - mark = -1; - return this; -} -``` - -接着是`clear()`,它相当于是将整个缓冲区回归到最初的状态了: - -```java -public final Buffer clear() { - position = 0; //同上 - limit = capacity; //limit变回capacity - mark = -1; - return this; -} -``` - -到这里,关于缓冲区的一些其他操作,我们就讲解到此。 - -### 缓冲区比较 - -缓冲区之间是可以进行比较的,我们可以看到equals方法和compareTo方法都是被重写了的,我们首先来看看`equals`方法,注意,它是判断两个缓冲区剩余的内容是否一致: - -```java -public boolean equals(Object ob) { - if (this == ob) //要是两个缓冲区是同一个对象,肯定一样 - return true; - if (!(ob instanceof IntBuffer)) //类型不是IntBuffer那也不用比了 - return false; - IntBuffer that = (IntBuffer)ob; //转换为IntBuffer - int thisPos = this.position(); //获取当前缓冲区的相关信息 - int thisLim = this.limit(); - int thatPos = that.position(); //获取另一个缓冲区的相关信息 - int thatLim = that.limit(); - int thisRem = thisLim - thisPos; - int thatRem = thatLim - thatPos; - if (thisRem < 0 || thisRem != thatRem) //如果剩余容量小于0或是两个缓冲区的剩余容量不一样,也不行 - return false; - //注意比较的是剩余的内容 - for (int i = thisLim - 1, j = thatLim - 1; i >= thisPos; i--, j--) //从最后一个开始倒着往回比剩余的区域 - if (!equals(this.get(i), that.get(j))) - return false; //只要发现不一样的就不用继续了,直接false - return true; //上面的比较都没问题,那么就true -} - -private static boolean equals(int x, int y) { - return x == y; -} -``` - -那么我们按照它的思路来验证一下: - -```java -public static void main(String[] args) { - IntBuffer buffer1 = IntBuffer.wrap(new int[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 0}); - IntBuffer buffer2 = IntBuffer.wrap(new int[]{6, 5, 4, 3, 2, 1, 7, 8, 9, 0}); - System.out.println(buffer1.equals(buffer2)); //直接比较 - - buffer1.position(6); - buffer2.position(6); - System.out.println(buffer1.equals(buffer2)); //比较从下标6开始的剩余内容 -} -``` - -可以看到结果就是我们所想的那样: - -![image-20220424145009464](https://tva1.sinaimg.cn/large/e6c9d24ely1h1ktaynv7nj21kw02gglg.jpg) - -那么我们接着来看比较,`compareTo`方法,它实际上是`Comparable`接口提供的方法,它实际上比较的也是pos开始剩余的内容: - -```java -public int compareTo(IntBuffer that) { - int thisPos = this.position(); //获取并计算两个缓冲区的pos和remain - int thisRem = this.limit() - thisPos; - int thatPos = that.position(); - int thatRem = that.limit() - thatPos; - int length = Math.min(thisRem, thatRem); //选取一个剩余空间最小的出来 - if (length < 0) //如果最小的小于0,那就返回-1 - return -1; - int n = thisPos + Math.min(thisRem, thatRem); //计算n的值当前的pos加上剩余的最小空间 - for (int i = thisPos, j = thatPos; i < n; i++, j++) { //从两个缓冲区的当前位置开始,一直到n结束 - int cmp = compare(this.get(i), that.get(j)); //比较 - if (cmp != 0) - return cmp; //只要出现不相同的,那么就返回比较出来的值 - } - return thisRem - thatRem; //如果没比出来个所以然,那么就比长度 -} - -private static int compare(int x, int y) { - return Integer.compare(x, y); -} -``` - -这里我们就不多做介绍了。 - -### 只读缓冲区 - -接着我们来看看只读缓冲区,只读缓冲区就像其名称一样,它只能进行读操作,而不允许进行写操作。 - -那么我们怎么创建只读缓冲区呢? - -* `public abstract IntBuffer asReadOnlyBuffer();` - 基于当前缓冲区生成一个只读的缓冲区。 - -我们来看看此方法的具体实现: - -```java -public IntBuffer asReadOnlyBuffer() { - return new HeapIntBufferR(hb, //注意这里并不是直接创建了HeapIntBuffer,而是HeapIntBufferR,并且直接复制的hb数组 - this.markValue(), - this.position(), - this.limit(), - this.capacity(), - offset); -} -``` - -那么这个HeapIntBufferR类跟我们普通的HeapIntBuffer有什么不同之处呢? - -![image-20220424150625847](https://tva1.sinaimg.cn/large/e6c9d24ely1h1ktrvyy39j221e0f8ac2.jpg) - -可以看到它是继承自HeapIntBuffer的,那么我们来看看它的实现有什么不同: - -```java -protected HeapIntBufferR(int[] buf, - int mark, int pos, int lim, int cap, - int off) -{ - super(buf, mark, pos, lim, cap, off); - this.isReadOnly = true; -} -``` - -可以看到在其构造方法中,除了直接调用父类的构造方法外,还会将`isReadOnly`标记修改为true,我们接着来看put操作有什么不同之处: - -```java -public boolean isReadOnly() { - return true; -} - -public IntBuffer put(int x) { - throw new ReadOnlyBufferException(); -} - -public IntBuffer put(int i, int x) { - throw new ReadOnlyBufferException(); -} - -public IntBuffer put(int[] src, int offset, int length) { - throw new ReadOnlyBufferException(); -} - -public IntBuffer put(IntBuffer src) { - throw new ReadOnlyBufferException(); -} -``` - -可以看到所有的put方法全部凉凉,只要调用就会直接抛出ReadOnlyBufferException异常。但是其他get方法依然没有进行重写,也就是说get操作还是可以正常使用的,但是只要是写操作就都不行: - -```java -public static void main(String[] args) { - IntBuffer buffer = IntBuffer.wrap(new int[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 0}); - IntBuffer readBuffer = buffer.asReadOnlyBuffer(); - - System.out.println(readBuffer.isReadOnly()); - System.out.println(readBuffer.get()); - readBuffer.put(0, 666); -} -``` - -可以看到结果为: - -![image-20220424151322831](https://tva1.sinaimg.cn/large/e6c9d24ely1h1ktz4pkhtj21ue04oab1.jpg) - -这就是只读状态下的缓冲区。 - -### ByteBuffer和CharBuffer - -通过前面的学习,我们基本上已经了解了缓冲区的使用,但是都是基于IntBuffer进行讲解,现在我们来看看另外两种基本类型的缓冲区ByteBuffer和CharBuffer,因为ByteBuffer底层存放的是很多单个byte字节,所以会有更多的玩法,同样CharBuffer是一系列字节,所以也有很多便捷操作。 - -我们先来看看ByteBuffer,我们可以直接点进去看: - -```java -public abstract class ByteBuffer extends Buffer implements Comparable { - final byte[] hb; // Non-null only for heap buffers - final int offset; - boolean isReadOnly; // Valid only for heap buffers - .... -``` - -可以看到如果也是使用堆缓冲区子类实现,那么依然是一个`byte[]`的形式保存数据。我们来尝试使用一下: - -```java -public static void main(String[] args) { - ByteBuffer buffer = ByteBuffer.allocate(10); - //除了直接丢byte进去之外,我们也可以丢其他的基本类型(注意容量消耗) - buffer.putInt(Integer.MAX_VALUE); //丢个int的最大值进去,注意一个int占4字节 - System.out.println("当前缓冲区剩余字节数:"+buffer.remaining()); //只剩6个字节了 - - //我们来尝试读取一下,记得先翻转 - buffer.flip(); - while (buffer.hasRemaining()) { - System.out.println(buffer.get()); //一共四个字节 - } -} -``` - -最后的结果为: - -![image-20220424153520843](https://tva1.sinaimg.cn/large/e6c9d24ely1h1kulzci1zj21cc05kaa3.jpg) - -可以看到第一个byte为127、然后三个都是-1,我们来分析一下: - -* `127` 转换为二进制补码形式就是 `01111111`,而`-1`转换为二进制补码形式为`11111111` - -那也就是说,第一个字节是01111111,而后续字节就是11111111,把它们拼接在一起: - -* 二进制补码表示`01111111 11111111 11111111 11111111` 转换为十进制就是`2147483647`,也就是int的最大值。 - -那么根据我们上面的推导,各位能否计算得到下面的结果呢? - -```java -public static void main(String[] args) { - ByteBuffer buffer = ByteBuffer.allocate(10); - buffer.put((byte) 0); - buffer.put((byte) 0); - buffer.put((byte) 1); - buffer.put((byte) -1); - - buffer.flip(); //翻转一下 - System.out.println(buffer.getInt()); //以int形式获取,那么就是一次性获取4个字节 -} -``` - -经过上面的计算,得到的结果就是: - -* 上面的数据以二进制补码的形式表示为:`00000000 00000000 00000001 11111111` -* 将其转换为十进制那么就是:256 + 255 = 511 - -好吧,再来个魔鬼问题,把第一个换成1呢:`10000000 00000000 00000001 11111111`,自己算。 - -我们接着来看看CharBuffer,这种缓冲区实际上也是保存一大堆char类型的数据: - -```java -public static void main(String[] args) { - CharBuffer buffer = CharBuffer.allocate(10); - buffer.put("lbwnb"); //除了可以直接丢char之外,字符串也可以一次性丢进入 - System.out.println(Arrays.toString(buffer.array())); -} -``` - -但是正是得益于char数组,它包含了很多的字符串操作,可以一次性存放一整个字符串。我们甚至还可以将其当做一个String来进行处理: - -```java -public static void main(String[] args) { - CharBuffer buffer = CharBuffer.allocate(10); - buffer.put("lbwnb"); - buffer.append("!"); //可以像StringBuilder一样使用append来继续添加数据 - - System.out.println("剩余容量:"+buffer.remaining()); //已经用了6个字符了 - - buffer.flip(); - System.out.println("整个字符串为:"+buffer); //直接将内容转换为字符串 - System.out.println("第3个字符是:"+buffer.charAt(2)); //直接像String一样charAt - - buffer //也可以转换为IntStream进行操作 - .chars() - .filter(i -> i < 'l') - .forEach(i -> System.out.print((char) i)); -} -``` - -当然除了一些常规操作之外,我们还可以直接将一个字符串作为参数创建: - -```java -public static void main(String[] args) { - //可以直接使用wrap包装一个字符串,但是注意,包装出来之后是只读的 - CharBuffer buffer = CharBuffer.wrap("收藏等于学会~"); - System.out.println(buffer); - - buffer.put("111"); //这里尝试进行一下写操作 -} -``` - -可以看到结果也是我们预料中的: - -![image-20220424161925938](https://tva1.sinaimg.cn/large/e6c9d24ely1h1kvvus5tej219a06c0u1.jpg) - -对于这两个比较特殊的缓冲区,我们就暂时讲解到这里。 - -### 直接缓冲区 - -**注意:**推荐学习完成JVM篇再来学习这一部分。 - -最后我们来看一下直接缓冲区,我们前面一直使用的都是堆缓冲区,也就是说实际上数据是保存在一个数组中的,如果你已经完成了JVM篇的学习,一定知道实际上占用的是堆内存,而我们也可以创建一个直接缓冲区,也就是申请堆外内存进行数据保存,采用操作系统本地的IO,相比堆缓冲区会快一些。 - -那么怎么使用直接缓冲区呢?我们可以通过`allocateDirect`方法来创建: - -```java -public static void main(String[] args) { - //这里我们申请一个直接缓冲区 - ByteBuffer buffer = ByteBuffer.allocateDirect(10); - //使用方式基本和之前是一样的 - buffer.put((byte) 66); - buffer.flip(); - System.out.println(buffer.get()); -} -``` - -我们来看看这个`allocateDirect`方法是如何创建一个直接缓冲区的: - -```java -public static ByteBuffer allocateDirect(int capacity) { - return new DirectByteBuffer(capacity); -} -``` - -这个方法直接创建了一个新的DirectByteBuffer对象,那么这个类又是怎么进行创建的呢? - -![image-20220424163028578](https://tva1.sinaimg.cn/large/e6c9d24ely1h1kw7cc7vnj223y0f80v0.jpg) - -可以看到它并不是直接继承自ByteBuffer,而是MappedByteBuffer,并且实现了接口DirectBuffer,我们先来看看这个接口: - -```java -public interface DirectBuffer { - public long address(); //获取内存地址 - public Object attachment(); //附加对象,这是为了保证某些情况下内存不被释放,我们后面细谈 - public Cleaner cleaner(); //内存清理类 -} -``` - -```java -public abstract class MappedByteBuffer extends ByteBuffer { - //这三个方法目前暂时用不到,后面文件再说 - public final MappedByteBuffer load(); - public final boolean isLoaded(); - public final MappedByteBuffer force(); -} -``` - -接着我们来看看DirectByteBuffer类的成员变量: - -```java -// 把Unsafe类取出来 -protected static final Unsafe unsafe = Bits.unsafe(); - -// 在内存中直接创建的内存空间地址 -private static final long arrayBaseOffset = (long)unsafe.arrayBaseOffset(byte[].class); - -// 是否具有非对齐访问能力,根据CPU架构而定,intel、AMD、AppleSilicon 都是支持的 -protected static final boolean unaligned = Bits.unaligned(); - -// 直接缓冲区的内存地址,为了提升速度就放到Buffer类中去了 -// protected long address; - -// 附加对象,一会有大作用 -private final Object att; -``` - -接着我们来看看构造方法: - -```java -DirectByteBuffer(int cap) { // package-private - super(-1, 0, cap, cap); - boolean pa = VM.isDirectMemoryPageAligned(); //是否直接内存分页对齐,需要额外计算 - int ps = Bits.pageSize(); - long size = Math.max(1L, (long)cap + (pa ? ps : 0)); //计算出最终需要申请的大小 - //判断堆外内存是否足够,够的话就作为保留内存 - Bits.reserveMemory(size, cap); - - long base = 0; - try { - //通过Unsafe申请内存空间,并得到内存地址 - base = unsafe.allocateMemory(size); - } catch (OutOfMemoryError x) { - //申请失败就取消一开始的保留内存 - Bits.unreserveMemory(size, cap); - throw x; - } - //批量将申请到的这一段内存每个字节都设定为0 - unsafe.setMemory(base, size, (byte) 0); - if (pa && (base % ps != 0)) { - // Round up to page boundary - address = base + ps - (base & (ps - 1)); - } else { - //将address变量(在Buffer中定义)设定为base的地址 - address = base; - } - //创建一个针对于此缓冲区的Cleaner,由于是堆外内存,所以现在由它来进行内存清理 - cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); - att = null; -} -``` - -可以看到在构造方法中,是直接通过Unsafe类来申请足够的堆外内存保存数据,那么当我们不使用此缓冲区时,内存会被如何清理呢?我们来看看这个Cleaner: - -```java -public class Cleaner extends PhantomReference{ //继承自鬼引用,也就是说此对象会存放一个没有任何引用的对象 - - //引用队列,PhantomReference构造方法需要 - private static final ReferenceQueue dummyQueue = new ReferenceQueue<>(); - - //执行清理的具体流程 - private final Runnable thunk; - - static private Cleaner first = null; //Cleaner双向链表,每创建一个Cleaner对象都会添加一个结点 - - private Cleaner - next = null, - prev = null; - - private static synchronized Cleaner add(Cleaner cl) { //添加操作会让新来的变成新的头结点 - if (first != null) { - cl.next = first; - first.prev = cl; - } - first = cl; - return cl; - } - - //可以看到创建鬼引用的对象就是传进的缓冲区对象 - private Cleaner(Object referent, Runnable thunk) { - super(referent, dummyQueue); - //清理流程实际上是外面的Deallocator - this.thunk = thunk; - } - - //通过此方法创建一个新的Cleaner - public static Cleaner create(Object ob, Runnable thunk) { - if (thunk == null) - return null; - return add(new Cleaner(ob, thunk)); //调用add方法将Cleaner添加到队列 - } - - //清理操作 - public void clean() { - if (!remove(this)) - return; //进行清理操作时会从双向队列中移除当前Cleaner,false说明已经移除过了,直接return - try { - thunk.run(); //这里就是直接执行具体清理流程 - } catch (final Throwable x) { - ... - } - } -``` - -那么我们先来看看具体的清理程序在做些什么,Deallocator是在直接缓冲区中声明的: - -```java -private static class Deallocator implements Runnable { - - private static Unsafe unsafe = Unsafe.getUnsafe(); - - private long address; //内存地址 - private long size; //大小 - private int capacity; //申请的容量 - - private Deallocator(long address, long size, int capacity) { - assert (address != 0); - this.address = address; - this.size = size; - this.capacity = capacity; - } - - public void run() { //具体的清理操作 - if (address == 0) { - // Paranoia - return; - } - unsafe.freeMemory(address); //这里是直接调用了Unsafe进行内存释放操作 - address = 0; //内存地址改为0,NULL - Bits.unreserveMemory(size, capacity); //取消一开始的保留内存 - } -} -``` - -好了,现在我们可以明确在清理的时候实际上也是调用Unsafe类进行内存释放操作,那么,这个清理操作具体是在什么时候进行的呢?首先我们要明确,如果是普通的堆缓冲区,由于使用的数组,那么一旦此对象没有任何引用时,就随时都会被GC给回收掉,但是现在是堆外内存,只能我们手动进行内存回收,那么当DirectByteBuffer也失去引用时,会不会触发内存回收呢? - -答案是可以的,还记得我们刚刚看到Cleaner是PhantomReference的子类吗,而DirectByteBuffer是被鬼引用的对象,而具体的清理操作是Cleaner类的clean方法,莫非这两者有什么联系吗? - -你别说,还真有,我们直接看到PhantomReference的父类Reference,我们会发现这样一个类: - -```java -private static class ReferenceHandler extends Thread { - ... - static { - // 预加载并初始化 InterruptedException 和 Cleaner 类 - // 以避免出现在循环运行过程中时由于内存不足而无法加载 - ensureClassInitialized(InterruptedException.class); - ensureClassInitialized(Cleaner.class); - } - - public void run() { - while (true) { - tryHandlePending(true); //这里是一个无限循环调用tryHandlePending方法 - } - } -} -``` - -```java -private T referent; /* 会被GC回收的对象,也就是我们给过来被引用的对象 */ - -volatile ReferenceQueue queue; //引用队列,可以和下面的next搭配使用,形成链表 -//Reference对象也是一个一个连起来的节点,这样才能放到ReferenceQueue中形成链表 -volatile Reference next; - -//即将被GC的引用链表 -transient private Reference discovered; /* 由虚拟机操作 */ - -//pending与discovered一起构成了一个pending单向链表,标记为static类所有,pending为链表的头节点,discovered为链表当前 -//Reference节点指向下一个节点的引用,这个队列是由JVM构建的,当对象除了被reference引用之外没有其它强引用了,JVM就会将指向 -//需要回收的对象的Reference对象都放入到这个队列里面,这个队列会由下面的 Reference Hander 线程来处理。 -private static Reference pending = null; -``` - -```java -static { //Reference类的静态代码块 - ThreadGroup tg = Thread.currentThread().getThreadGroup(); - for (ThreadGroup tgn = tg; - tgn != null; - tg = tgn, tgn = tg.getParent()); - Thread handler = new ReferenceHandler(tg, "Reference Handler"); //在一开始的时候就会创建 - handler.setPriority(Thread.MAX_PRIORITY); //以最高优先级启动 - handler.setDaemon(true); //此线程直接作为一个守护线程 - handler.start(); //也就是说在一开始的时候这个守护线程就会启动 - - ... -} -``` - -那么也就是说Reference Handler线程是在一开始就启动了,那么我们的关注点可以放在`tryHandlePending`方法上,看看这玩意到底在做个啥: - -```java -static boolean tryHandlePending(boolean waitForNotify) { - Reference r; - Cleaner c; - try { - synchronized (lock) { //加锁办事 - //当Cleaner引用的DirectByteBuffer对象即将被回收时,pending会变成此Cleaner对象 - //这里判断到pending不为null时就需要处理一下对象销毁了 - if (pending != null) { - r = pending; - // 'instanceof' 有时会导致内存溢出,所以将r从链表中移除之前就进行类型判断 - // 如果是Cleaner类型就给到c - c = r instanceof Cleaner ? (Cleaner) r : null; - // 将pending更新为链表下一个待回收元素 - pending = r.discovered; - r.discovered = null; //r不再引用下一个节点 - } else { - //否则就进入等待 - if (waitForNotify) { - lock.wait(); - } - return waitForNotify; - } - } - } catch (OutOfMemoryError x) { - Thread.yield(); - return true; - } catch (InterruptedException x) { - return true; - } - - // 如果元素是Cleaner类型,c在上面就会被赋值,这里就会执行其clean方法(破案了) - if (c != null) { - c.clean(); - return true; - } - - ReferenceQueue q = r.queue; - if (q != ReferenceQueue.NULL) q.enqueue(r); //这个是引用队列,实际上就是我们之前在JVM篇中讲解的入队机制 - return true; -} -``` - -通过对源码的解读,我们就了解了直接缓冲区的内存加载释放整个流程。和堆缓冲区一样,当直接缓冲区没有任何强引用时,就有机会被GC正常回收掉并自动释放申请的内存。 - -我们接着来看看直接缓冲区的读写操作是如何进行的: - -```java -public byte get() { - return ((unsafe.getByte(ix(nextGetIndex())))); //直接通过Unsafe类读取对应地址上的byte数据 -} -``` - -```java -private long ix(int i) { - return address + ((long)i << 0); //ix现在是内存地址再加上i -} -``` - -我们接着来看看写操作: - -```java -public ByteBuffer put(byte x) { - unsafe.putByte(ix(nextPutIndex()), ((x))); - return this; -} -``` - -可以看到无论是读取还是写入操作都是通过Unsafe类操作对应的内存地址完成的。 - -那么它的复制操作是如何实现的呢? - -```java -public ByteBuffer duplicate() { - return new DirectByteBuffer(this, - this.markValue(), - this.position(), - this.limit(), - this.capacity(), - 0); -} -``` - -```java -DirectByteBuffer(DirectBuffer db, // 这里给的db是进行复制操作的DirectByteBuffer对象 - int mark, int pos, int lim, int cap, - int off) { - super(mark, pos, lim, cap); - address = db.address() + off; //直接继续使用之前申请的内存空间 - cleaner = null; //因为用的是之前的内存空间,已经有对应的Cleaner了,这里不需要再搞一个 - att = db; //将att设定为此对象 -} -``` - -可以看到,如果是进行复制操作,那么会直接会继续使用执行复制操作的DirectByteBuffer申请的内存空间。不知道各位是否能够马上联想到一个问题,我们知道,如果执行复制操作的DirectByteBuffer对象失去了强引用被回收,那么就会触发Cleaner并进行内存释放,但是有个问题就是,这段内存空间可能复制出来的DirectByteBuffer对象还需要继续使用,这时肯定是不能进行回收的,所以说这里使用了att变量将之前的DirectByteBuffer对象进行引用,以防止其失去强引用被垃圾回收,所以只要不是原来的DirectByteBuffer对象和复制出来的DirectByteBuffer对象都失去强引用时,就不会导致这段内存空间被回收。 - -这样,我们之前的未解之谜为啥有个`att`也就得到答案了,有关直接缓冲区的介绍,就到这里为止。 - -*** - -## 通道 - -前面我们学习了NIO的基石——缓冲区,那么缓冲区具体用在什么地方呢,在本板块我们学习通道之后,相信各位就能知道了。那么,什么是通道呢? - -在传统IO中,我们都是通过流进行传输,数据会源源不断从流中传出;而在NIO中,数据是放在缓冲区中进行管理,再使用通道将缓冲区中的数据传输到目的地。 - -### 通道接口层次 - -通道的根基接口是`Channel`,所以的派生接口和类都是从这里开始的,我们来看看它定义了哪些基本功能: - -```java -public interface Channel extends Closeable { - //通道是否处于开启状态 - public boolean isOpen(); - - //因为通道开启也需要关闭,所以实现了Closeable接口,所以这个方法懂的都懂 - public void close() throws IOException; -} -``` - -我们接着来看看它的一些子接口,首先是最基本的读写操作: - -```JAVA -public interface ReadableByteChannel extends Channel { - //将通道中的数据读取到给定的缓冲区中 - public int read(ByteBuffer dst) throws IOException; -} -``` - -```java -public interface WritableByteChannel extends Channel { - //将给定缓冲区中的数据写入到通道中 - public int write(ByteBuffer src) throws IOException; -} -``` - -有了读写功能后,最后整合为了一个ByteChannel接口: - -```java -public interface ByteChannel extends ReadableByteChannel, WritableByteChannel{ - -} -``` - -![image-20220425092355354](https://tva1.sinaimg.cn/large/e6c9d24egy1h1lphvijimj223g0lajug.jpg) - -在ByteChannel之下,还有更多的派生接口: - -```java -//允许保留position和更改position的通道,以及对通道连接实体的相关操作 -public interface SeekableByteChannel extends ByteChannel { - ... - - //获取当前的position - long position() throws IOException; - - //修改当前的position - SeekableByteChannel position(long newPosition) throws IOException; - - //返回此通道连接到的实体(比如文件)的当前大小 - long size() throws IOException; - - //将此通道连接到的实体截断(比如文件,截断之后,文件后面一半就没了)为给定大小 - SeekableByteChannel truncate(long size) throws IOException; -} -``` - -接着我们来看,除了读写之外,Channel还可以具有响应中断的能力: - -```java -public interface InterruptibleChannel extends Channel { - //当其他线程调用此方法时,在此通道上处于阻塞状态的线程会直接抛出 AsynchronousCloseException 异常 - public void close() throws IOException; -} -``` - -```java -//这是InterruptibleChannel的抽象实现,完成了一部分功能 -public abstract class AbstractInterruptibleChannel implements Channel, InterruptibleChannel { - //加锁关闭操作用到 - private final Object closeLock = new Object(); - //当前Channel的开启状态 - private volatile boolean open = true; - - protected AbstractInterruptibleChannel() { } - - //关闭操作实现 - public final void close() throws IOException { - synchronized (closeLock) { //同时只能有一个线程进行此操作,加锁 - if (!open) //如果已经关闭了,那么就不用继续了 - return; - open = false; //开启状态变成false - implCloseChannel(); //开始关闭通道 - } - } - - //该方法由 close 方法调用,以执行关闭通道的具体操作,仅当通道尚未关闭时才调用此方法,不会多次调用。 - protected abstract void implCloseChannel() throws IOException; - - public final boolean isOpen() { - return open; - } - - //开始阻塞(有可能一直阻塞下去)操作之前,需要调用此方法进行标记, - protected final void begin() { - ... - } - - //阻塞操作结束之后,也需要需要调用此方法,为了防止异常情况导致此方法没有被调用,建议放在finally中 - protected final void end(boolean completed) - ... - } - - ... -} -``` - -而之后的一些实现类,都是基于这些接口定义的方法去进行实现的,比如FileChannel: - -![image-20220426090845530](https://tva1.sinaimg.cn/large/e6c9d24ely1h1muofbqbmj22520pe0yu.jpg) - -这样,我们就大致了解了一下通道相关的接口定义,那么我来看看具体是如何如何使用的。 - -比如现在我们要实现从输入流中读取数据然后打印出来,那么之前传统IO的写法: - -```java -public static void main(String[] args) throws IOException { - //数组创建好,一会用来存放从流中读取到的数据 - byte[] data = new byte[10]; - //直接使用输入流 - InputStream in = System.in; - while (true) { - int len; - while ((len = in.read(data)) >= 0) { //将输入流中的数据一次性读取到数组中 - System.out.print("读取到一批数据:"+new String(data, 0, len)); //读取了多少打印多少 - } - } -} -``` - -而现在我们使用通道之后: - -```java -public static void main(String[] args) throws IOException { - //缓冲区创建好,一会就靠它来传输数据 - ByteBuffer buffer = ByteBuffer.allocate(10); - //将System.in作为输入源,一会Channel就可以从这里读取数据,然后通过缓冲区装载一次性传递数据 - ReadableByteChannel readChannel = Channels.newChannel(System.in); - while (true) { - //将通道中的数据写到缓冲区中,缓冲区最多一次装10个 - readChannel.read(buffer); - //写入操作结束之后,需要进行翻转,以便接下来的读取操作 - buffer.flip(); - //最后转换成String打印出来康康 - System.out.println("读取到一批数据:"+new String(buffer.array(), 0, buffer.remaining())); - //回到最开始的状态 - buffer.clear(); - } -} -``` - -乍一看,好像感觉也没啥区别,不就是把数组换成缓冲区了吗,效果都是一样的,数据也是从Channel中读取得到,并且通过缓冲区进行数据装载然后得到结果,但是,Channel不像流那样是单向的,它就像它的名字一样,一个通道可以从一端走到另一端,也可以从另一端走到这一端,我们后面进行介绍。 - -### 文件传输FileChannel - -前面我们介绍了通道的基本情况,这里我们就来尝试实现一下文件的读取和写入,在传统IO中,文件的写入和输出都是依靠FileOutputStream和FileInputStream来完成的: - -```java -public static void main(String[] args) throws IOException { - try(FileOutputStream out = new FileOutputStream("test.txt"); - FileInputStream in = new FileInputStream("test.txt")){ - String data = "伞兵一号卢本伟准备就绪!"; - out.write(data.getBytes()); //向文件的输出流中写入数据,也就是把数据写到文件中 - out.flush(); - - byte[] bytes = new byte[in.available()]; - in.read(bytes); //从文件的输入流中读取文件的信息 - System.out.println(new String(bytes)); - } -} -``` - -而现在,我们只需要通过一个FileChannel就可以完成这两者的操作,获取文件通道的方式有以下几种: - -```java -public static void main(String[] args) throws IOException { - //1. 直接通过输入或输出流获取对应的通道 - FileInputStream in = new FileInputStream("test.txt"); - //但是这里的通道只支持读取或是写入操作 - FileChannel channel = in.getChannel(); - //创建一个容量为128的缓冲区 - ByteBuffer buffer = ByteBuffer.allocate(128); - //从通道中将数据读取到缓冲区中 - channel.read(buffer); - //翻转一下,接下来要读取了 - buffer.flip(); - - System.out.println(new String(buffer.array(), 0, buffer.remaining())); -} -``` - -可以看到通过输入流获取的文件通道读取是没有任何问题的,但是写入操作: - -```java -public static void main(String[] args) throws IOException { - //1. 直接通过输入或输出流获取对应的通道 - FileInputStream in = new FileInputStream("test.txt"); - //但是这里的通道只支持读取或是写入操作 - FileChannel channel = in.getChannel(); - //尝试写入一下 - channel.write(ByteBuffer.wrap("伞兵一号卢本伟准备就绪!".getBytes())); -} -``` - -![image-20220426103818019](https://tva1.sinaimg.cn/large/e6c9d24ely1h1mx9in88jj21l403mgmo.jpg) - -直接报错,说明只支持读取操作,那么输出流呢? - -```java -public static void main(String[] args) throws IOException { - //1. 直接通过输入或输出流获取对应的通道 - FileOutputStream out = new FileOutputStream("test.txt"); - //但是这里的通道只支持读取或是写入操作 - FileChannel channel = out.getChannel(); - //尝试写入一下 - channel.write(ByteBuffer.wrap("伞兵一号卢本伟准备就绪!".getBytes())); -} -``` - -可以看到能够正常进行写入,但是读取呢? - -```java -public static void main(String[] args) throws IOException { - //1. 直接通过输入或输出流获取对应的通道 - FileOutputStream out = new FileOutputStream("test.txt"); - //但是这里的通道只支持读取或是写入操作 - FileChannel channel = out.getChannel(); - - ByteBuffer buffer = ByteBuffer.allocate(128); - //从通道中将数据读取到缓冲区中 - channel.read(buffer); - //翻转一下,接下来要读取了 - buffer.flip(); - - System.out.println(new String(buffer.array(), 0, buffer.remaining())); -} -``` - -![image-20220426104016649](https://tva1.sinaimg.cn/large/e6c9d24ely1h1mxbknxa4j21gg03ijsd.jpg) - -可以看到输出流生成的Channel又不支持读取,所以说本质上还是保持着输入输出流的特性,但是之前不是说Channel又可以输入又可以输出吗?这里我们来看看第二种方式: - -```java -//RandomAccessFile能够支持文件的随机访问,并且实现了数据流 -public class RandomAccessFile implements DataOutput, DataInput, Closeable { -``` - -我们可以通过RandomAccessFile来创建通道: - -```java -public static void main(String[] args) throws IOException { - /* - 通过RandomAccessFile进行创建,注意后面的mode有几种: - r 以只读的方式使用 - rw 读操作和写操作都可以 - rws 每当进行写操作,同步的刷新到磁盘,刷新内容和元数据 - rwd 每当进行写操作,同步的刷新到磁盘,刷新内容 - */ - try(RandomAccessFile f = new RandomAccessFile("test.txt", "")){ - - } -} -``` - -现在我们来测试一下它的读写操作: - -```java -public static void main(String[] args) throws IOException { - /* - 通过RandomAccessFile进行创建,注意后面的mode有几种: - r 以只读的方式使用 - rw 读操作和写操作都可以 - rws 每当进行写操作,同步的刷新到磁盘,刷新内容和元数据 - rwd 每当进行写操作,同步的刷新到磁盘,刷新内容 - */ - try(RandomAccessFile f = new RandomAccessFile("test.txt", "rw"); //这里设定为支持读写,这样创建的通道才能具有这些功能 - FileChannel channel = f.getChannel()){ //通过RandomAccessFile创建一个通道 - channel.write(ByteBuffer.wrap("伞兵二号马飞飞准备就绪!".getBytes())); - - System.out.println("写操作完成之后文件访问位置:"+channel.position()); //注意读取也是从现在的位置开始 - channel.position(0); //需要将位置变回到最前面,这样下面才能从文件的最开始进行读取 - - ByteBuffer buffer = ByteBuffer.allocate(128); - channel.read(buffer); - buffer.flip(); - - System.out.println(new String(buffer.array(), 0, buffer.remaining())); - } -} -``` - -可以看到,一个FileChannel既可以完成文件读取,也可以完成文件的写入。 - -除了基本的读写操作,我们也可以直接对文件进行截断: - -```java -public static void main(String[] args) throws IOException { - try(RandomAccessFile f = new RandomAccessFile("test.txt", "rw"); - FileChannel channel = f.getChannel()){ - //截断文件,只留前20个字节 - channel.truncate(20); - - ByteBuffer buffer = ByteBuffer.allocate(128); - channel.read(buffer); - buffer.flip(); - System.out.println(new String(buffer.array(), 0, buffer.remaining())); - } -} -``` - -可以看到文件的内容直接被截断了,文件内容就只剩一半了。 - -当然,如果我们要进行文件的拷贝,也是很方便的,只需要使用通道就可以,比如我们现在需要将一个通道的数据写入到另一个通道,就可以直接使用transferTo方法: - -```java -public static void main(String[] args) throws IOException { - try(FileOutputStream out = new FileOutputStream("test2.txt"); - FileInputStream in = new FileInputStream("test.txt")){ - - FileChannel inChannel = in.getChannel(); //获取到test文件的通道 - inChannel.transferTo(0, inChannel.size(), out.getChannel()); //直接将test文件通道中的数据转到test2文件的通道中 - } -} -``` - -可以看到执行后,文件的内容全部被复制到另一个文件了。 - -当然,反向操作也是可以的: - -```java -public static void main(String[] args) throws IOException { - try(FileOutputStream out = new FileOutputStream("test2.txt"); - FileInputStream in = new FileInputStream("test.txt")){ - - FileChannel inChannel = in.getChannel(); //获取到test文件的通道 - out.getChannel().transferFrom(inChannel, 0, inChannel.size()); //直接将从test文件通道中传来的数据转给test2文件的通道 - } -} -``` - -当我们要编辑某个文件时,通过使用MappedByteBuffer类,可以将其映射到内存中进行编辑,编辑的内容会同步更新到文件中: - -```java -//注意一定要是可写的,不然无法进行修改操作 -try(RandomAccessFile f = new RandomAccessFile("test.txt", "rw"); - FileChannel channel = f.getChannel()){ - - //通过map方法映射文件的某一段内容,创建MappedByteBuffer对象 - //比如这里就是从第四个字节开始,映射10字节内容到内存中 - //注意这里需要使用MapMode.READ_WRITE模式,其他模式无法保存数据到文件 - MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_WRITE, 4, 10); - - //我们可以直接对在内存中的数据进行编辑,也就是编辑Buffer中的内容 - //注意这里写入也是从pos位置开始的,默认是从0开始,相对于文件就是从第四个字节开始写 - //注意我们只映射了10个字节,也就是写的内容不能超出10字节了 - buffer.put("yyds".getBytes()); - - //编辑完成后,通过force方法将数据写回文件的映射区域 - buffer.force(); -} -``` - -可以看到,文件的某一个区域已经被我们修改了,并且这里实际上使用的就是DirectByteBuffer直接缓冲区,效率还是很高的。 - -### 文件锁FileLock - -我们可以创建一个跨进程文件锁来防止多个进程之间的文件争抢操作(注意这里是进程,不是线程)FileLock是文件锁,它能保证同一时间只有一个进程(程序)能够修改它,或者都只可以读,这样就解决了多进程间的同步文件,保证了安全性。但是需要注意的是,它进程级别的,不是线程级别的,他可以解决多个进程并发访问同一个文件的问题,但是它不适用于控制同一个进程中多个线程对一个文件的访问。 - -那么我们来看看如何使用文件锁: - -```java -public static void main(String[] args) throws IOException, InterruptedException { - //创建RandomAccessFile对象,并拿到Channel - RandomAccessFile f = new RandomAccessFile("test.txt", "rw"); - FileChannel channel = f.getChannel(); - System.out.println(new Date() + " 正在尝试获取文件锁..."); - //接着我们直接使用lock方法进行加锁操作(如果其他进程已经加锁,那么会一直阻塞在这里) - //加锁操作支持对文件的某一段进行加锁,比如这里就是从0开始后的6个字节加锁,false代表这是一把独占锁 - //范围锁甚至可以提前加到一个还未写入的位置上 - FileLock lock = channel.lock(0, 6, false); - System.out.println(new Date() + " 已获取到文件锁!"); - Thread.sleep(5000); //假设要处理5秒钟 - System.out.println(new Date() + " 操作完毕,释放文件锁!"); - - //操作完成之后使用release方法进行锁释放 - lock.release(); -} -``` - -有关共享锁和独占锁: - -* 进程对文件加独占锁后,当前进程对文件可读可写,独占此文件,其它进程是不能读该文件进行读写操作的。 -* 进程对文件加共享锁后,进程可以对文件进行读操作,但是无法进行写操作,共享锁可以被多个进程添加,但是只要存在共享锁,就不能添加独占锁。 - -现在我们来启动两个进程试试看,我们需要在IDEA中配置一下两个启动项: - -![image-20220426153541728](https://tva1.sinaimg.cn/large/e6c9d24ely1h1n5uyrdjij21t40hsmzt.jpg) - -现在我们依次启动它们: - -![image-20220426153636218](https://tva1.sinaimg.cn/large/e6c9d24ely1h1n5vwim5ej21hc06ct9x.jpg) - -![image-20220426153648363](https://tva1.sinaimg.cn/large/e6c9d24ely1h1n5w43wzxj21ii06igmw.jpg) - -可以看到确实是两个进程同一时间只能有一个进行访问,而另一个需要等待锁释放。 - -那么如果我们申请的是文件的不同部分呢? - -```java -//其中一个进程锁 0 - 5 -FileLock lock = channel.lock(0, 6, false); -//另一个进程锁 6 - 11 -FileLock lock = channel.lock(6, 6, false); -``` - -可以看到,两个进程这时就可以同时进行加锁操作了,因为它们锁的是不同的段落。 - -那么要是交叉呢? - -```java -//其中一个进程锁 0 - 5 -FileLock lock = channel.lock(0, 6, false); -//另一个进程锁 3 - 8 -FileLock lock = channel.lock(3, 6, false); -``` - -可以看到交叉的情况下也是会出现阻塞的。 - -接着我们来看看共享锁,共享锁允许多个进程同时加锁,但是不能进行写操作: - -```java -public static void main(String[] args) throws IOException, InterruptedException { - RandomAccessFile f = new RandomAccessFile("test.txt", "rw"); - FileChannel channel = f.getChannel(); - System.out.println(new Date() + " 正在尝试获取文件锁..."); - //现在使用共享锁 - FileLock lock = channel.lock(0, Long.MAX_VALUE, true); - System.out.println(new Date() + " 已获取到文件锁!"); - //进行写操作 - channel.write(ByteBuffer.wrap(new Date().toString().getBytes())); - - System.out.println(new Date() + " 操作完毕,释放文件锁!"); - //操作完成之后使用release方法进行锁释放 - lock.release(); - } -``` - -当我们进行写操作时: - -![image-20220426223636761](https://tva1.sinaimg.cn/large/e6c9d24egy1h1ni0yi6vcj21t008ugo8.jpg) - -可以看到直接抛出异常,说另一个程序已锁定文件的一部分,进程无法访问(某些系统或是环境实测无效,比如UP主的arm架构MacOS就不生效,这个异常是在Windows环境下运行得到的) - -当然,我们也可以测试一下多个进行同时加共享锁: - -```java -public static void main(String[] args) throws IOException, InterruptedException { - RandomAccessFile f = new RandomAccessFile("test.txt", "rw"); - FileChannel channel = f.getChannel(); - System.out.println(new Date() + " 正在尝试获取文件锁..."); - - FileLock lock = channel.lock(0, Long.MAX_VALUE, true); - System.out.println(new Date() + " 已获取到文件锁!"); - Thread.sleep(5000); //假设要处理5秒钟 - System.out.println(new Date() + " 操作完毕,释放文件锁!"); - - lock.release(); -} -``` - -可以看到结果是多个进程都能加共享锁: - -![image-20220426224938834](https://tva1.sinaimg.cn/large/e6c9d24egy1h1niehdyqkj21eg03wgm2.jpg) - -![image-20220426224954291](https://tva1.sinaimg.cn/large/e6c9d24egy1h1nier022vj21go03mwex.jpg) - -当然,除了直接使用`lock()`方法进行加锁之外,我们也可以使用`tryLock()`方法以非阻塞方式获取文件锁,但是如果获取锁失败会得到null: - -```java -public static void main(String[] args) throws IOException, InterruptedException { - RandomAccessFile f = new RandomAccessFile("test.txt", "rw"); - FileChannel channel = f.getChannel(); - System.out.println(new Date() + " 正在尝试获取文件锁..."); - - FileLock lock = channel.tryLock(0, Long.MAX_VALUE, false); - System.out.println(lock); - Thread.sleep(5000); //假设要处理5秒钟 - - lock.release(); -} -``` - -可以看到,两个进程都去尝试获取独占锁: - -![image-20220426230102206](https://tva1.sinaimg.cn/large/e6c9d24egy1h1niqbuoygj218w02mq39.jpg) - -![image-20220426230117926](https://tva1.sinaimg.cn/large/e6c9d24egy1h1niqlky3ij21ek04igma.jpg) - -第一个成功加锁的进程获得了对应的锁对象,而第二个进程直接得到的是`null`。 - -到这里,有关文件锁的相关内容就差不多了。 - -*** - -## 多路复用网络通信 - -前面我们已经介绍了NIO框架的两大核心:Buffer和Channel,我们接着来看看最后一个内容。 - -### 传统阻塞I/O网络通信 - -说起网络通信,相信各位并不陌生,正是因为网络的存在我们才能走进现代化的社会,在JavaWeb阶段,我们学习了如何使用Socket建立TCP连接进行网络通信: - -```java -public static void main(String[] args) { - try(ServerSocket server = new ServerSocket(8080)){ //将服务端创建在端口8080上 - System.out.println("正在等待客户端连接..."); - Socket socket = server.accept(); - System.out.println("客户端已连接,IP地址为:"+socket.getInetAddress().getHostAddress()); - BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); //通过 - System.out.print("接收到客户端数据:"); - System.out.println(reader.readLine()); - OutputStreamWriter writer = new OutputStreamWriter(socket.getOutputStream()); - writer.write("已收到!"); - writer.flush(); - }catch (IOException e){ - e.printStackTrace(); - } -} -``` - -```java -public static void main(String[] args) { - try (Socket socket = new Socket("localhost", 8080); - Scanner scanner = new Scanner(System.in)){ - System.out.println("已连接到服务端!"); - OutputStream stream = socket.getOutputStream(); - OutputStreamWriter writer = new OutputStreamWriter(stream); //通过转换流来帮助我们快速写入内容 - System.out.println("请输入要发送给服务端的内容:"); - String text = scanner.nextLine(); - writer.write(text+'\n'); //因为对方是readLine()这里加个换行符 - writer.flush(); - System.out.println("数据已发送:"+text); - BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); - System.out.println("收到服务器返回:"+reader.readLine()); - }catch (IOException e){ - System.out.println("服务端连接失败!"); - e.printStackTrace(); - }finally { - System.out.println("客户端断开连接!"); - } -} -``` - -当然,我们也可以使用前面讲解的通道来进行通信: - -```java -public static void main(String[] args) { - //创建一个新的ServerSocketChannel,一会直接使用SocketChannel进行网络IO操作 - try (ServerSocketChannel serverChannel = ServerSocketChannel.open()){ - //依然是将其绑定到8080端口 - serverChannel.bind(new InetSocketAddress(8080)); - //同样是调用accept()方法,阻塞等待新的连接到来 - SocketChannel socket = serverChannel.accept(); - //因为是通道,两端的信息都是可以明确的,这里获取远端地址,当然也可以获取本地地址 - System.out.println("客户端已连接,IP地址为:"+socket.getRemoteAddress()); - - //使用缓冲区进行数据接收 - ByteBuffer buffer = ByteBuffer.allocate(128); - socket.read(buffer); //SocketChannel同时实现了读写通道接口,所以可以直接进行双向操作 - buffer.flip(); - System.out.print("接收到客户端数据:"+new String(buffer.array(), 0, buffer.remaining())); - - //直接向通道中写入数据就行 - socket.write(ByteBuffer.wrap("已收到!".getBytes())); - - //记得关 - socket.close(); - } catch (IOException e) { - throw new RuntimeException(e); - } -} -``` - -```java -public static void main(String[] args) { - //创建一个新的SocketChannel,一会通过通道进行通信 - try (SocketChannel channel = SocketChannel.open(new InetSocketAddress("localhost", 8080)); - Scanner scanner = new Scanner(System.in)){ - System.out.println("已连接到服务端!"); - System.out.println("请输入要发送给服务端的内容:"); - String text = scanner.nextLine(); - //直接向通道中写入数据,真舒服 - channel.write(ByteBuffer.wrap(text.getBytes())); - - ByteBuffer buffer = ByteBuffer.allocate(128); - channel.read(buffer); //直接从通道中读取数据 - buffer.flip(); - System.out.println("收到服务器返回:"+new String(buffer.array(), 0, buffer.remaining())); - } catch (IOException e) { - throw new RuntimeException(e); - } -} -``` - -虽然可以通过传统的Socket进行网络通信,但是我们发现,如果要进行IO操作,我们需要单独创建一个线程来进行处理,比如现在有很多个客户端,服务端需要同时进行处理,那么如果我们要处理这些客户端的请求,那么我们就只能单独为其创建一个线程来进行处理: - -![image-20220427165019293](https://tva1.sinaimg.cn/large/e6c9d24ely1h1odmx2b3yj21o60dcwgh.jpg) - -虽然这样看起来比较合理,但是随着客户端数量的增加,如果要保持持续通信,那么就不能摧毁这些线程,而是需要一直保留(但是实际上很多时候只是保持连接,一直在阻塞等待客户端的读写操作,IO操作的频率很低,这样就白白占用了一条线程,很多时候都是站着茅坑不拉屎),但是我们的线程不可能无限制的进行创建,总有一天会耗尽服务端的资源,那么现在怎么办呢,关键是现在又有很多客户端源源不断地连接并进行操作,这时,我们就可以利用NIO为我们提供的多路复用编程模型。 - -我们来看看NIO为我们提供的模型: - -![image-20220427170247004](https://tva1.sinaimg.cn/large/e6c9d24ely1h1odzw1dk3j21oi0e2goy.jpg) - -服务端不再是一个单纯通过`accept()`方法来创建连接的机制了,而是根据客户端不同的状态,Selector会不断轮询,只有客户端在对应的状态时,比如真正开始读写操作时,才会创建线程或进行处理(这样就不会一直阻塞等待某个客户端的IO操作了),而不是创建之后需要一直保持连接,即使没有任何的读写操作。这样就不会因为占着茅坑不拉屎导致线程无限制地创建下去了。 - -通过这种方式,甚至单线程都能做到高效的复用,最典型的例子就是Redis了,因为内存的速度非常快,多线程上下文的开销就会显得有些拖后腿,还不如直接单线程简单高效,这也是为什么Redis单线程也能这么快的原因。 - -因此,我们就从NIO框架的第三个核心内容:Selector,开始讲起。 - -### 选择器与I/O多路复用 - -前面我们大概了解了一下选择器,我们知道,选择器是当具体有某一个状态(比如读、写、请求)已经就绪时,才会进行处理,而不是让我们的程序主动地进行等待。 - -既然我们现在需要实现IO多路复用,那么我们来看看常见的IO多路复用模型,也就是Selector的实现方案,比如现在有很多个用户连接到我们的服务器: - -* **select**:当这些连接出现具体的某个状态时,只是知道已经就绪了,但是不知道详具体是哪一个连接已经就绪,每次调用都进行线性遍历所有连接,时间复杂度为`O(n)`,并且存在最大连接数限制。 -* **poll**:同上,但是由于底层采用链表,所以没有最大连接数限制。 -* **epoll**:采用事件通知方式,当某个连接就绪,能够直接进行精准通知(这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的,只要就绪会会直接回调callback函数,实现精准通知,但是只有Linux支持这种方式),时间复杂度`O(1)`,Java在Linux环境下正是采用的这种模式进行实现的。 - -好了,既然多路复用模型了解完毕了,那么我们就来看看如何让我们的网络通信实现多路复用: - -```java -public static void main(String[] args) { - try (ServerSocketChannel serverChannel = ServerSocketChannel.open(); - Selector selector = Selector.open()){ //开启一个新的Selector,这玩意也是要关闭释放资源的 - serverChannel.bind(new InetSocketAddress(8080)); - //要使用选择器进行操作,必须使用非阻塞的方式,这样才不会像阻塞IO那样卡在accept(),而是直接通过,让选择器去进行下一步操作 - serverChannel.configureBlocking(false); - //将选择器注册到ServerSocketChannel中,后面是选择需要监听的时间,只有发生对应事件时才会进行选择,多个事件用 | 连接,注意,并不是所有的Channel都支持以下全部四个事件,可能只支持部分 - //因为是ServerSocketChannel这里我们就监听accept就可以了,等待客户端连接 - //SelectionKey.OP_CONNECT --- 连接就绪事件,表示客户端与服务器的连接已经建立成功 - //SelectionKey.OP_ACCEPT --- 接收连接事件,表示服务器监听到了客户连接,服务器可以接收这个连接了 - //SelectionKey.OP_READ --- 读 就绪事件,表示通道中已经有了可读的数据,可以执行读操作了 - //SelectionKey.OP_WRITE --- 写 就绪事件,表示已经可以向通道写数据了(这玩意比较特殊,一般情况下因为都是可以写入的,所以可能会无限循环) - serverChannel.register(selector, SelectionKey.OP_ACCEPT); - while (true) { //无限循环等待新的用户网络操作 - //每次选择都可能会选出多个已经就绪的网络操作,没有操作时会暂时阻塞 - int count = selector.select(); - System.out.println("监听到 "+count+" 个事件"); - Set selectionKeys = selector.selectedKeys(); - Iterator iterator = selectionKeys.iterator(); - while (iterator.hasNext()) { - SelectionKey key = iterator.next(); - //根据不同的事件类型,执行不同的操作即可 - if(key.isAcceptable()) { //如果当前ServerSocketChannel已经做好准备处理Accept - SocketChannel channel = serverChannel.accept(); - System.out.println("客户端已连接,IP地址为:"+channel.getRemoteAddress()); - //现在连接就建立好了,接着我们需要将连接也注册选择器,比如我们需要当这个连接有内容可读时就进行处理 - channel.configureBlocking(false); - channel.register(selector, SelectionKey.OP_READ); - //这样就在连接建立时完成了注册 - } else if(key.isReadable()) { //如果当前连接有可读的数据并且可以写,那么就开始处理 - SocketChannel channel = (SocketChannel) key.channel(); - ByteBuffer buffer = ByteBuffer.allocate(128); - channel.read(buffer); - buffer.flip(); - System.out.println("接收到客户端数据:"+new String(buffer.array(), 0, buffer.remaining())); - - //直接向通道中写入数据就行 - channel.write(ByteBuffer.wrap("已收到!".getBytes())); - //别关,说不定用户还要继续通信呢 - } - //处理完成后,一定记得移出迭代器,不然下次还有 - iterator.remove(); - } - } - } catch (IOException e) { - throw new RuntimeException(e); - } -} -``` - -接着我们来编写一下客户客户端: - -```java -public static void main(String[] args) { - //创建一个新的SocketChannel,一会通过通道进行通信 - try (SocketChannel channel = SocketChannel.open(new InetSocketAddress("localhost", 8080)); - Scanner scanner = new Scanner(System.in)){ - System.out.println("已连接到服务端!"); - while (true) { //咱给它套个无限循环,这样就能一直发消息了 - System.out.println("请输入要发送给服务端的内容:"); - String text = scanner.nextLine(); - //直接向通道中写入数据,真舒服 - channel.write(ByteBuffer.wrap(text.getBytes())); - System.out.println("已发送!"); - ByteBuffer buffer = ByteBuffer.allocate(128); - channel.read(buffer); //直接从通道中读取数据 - buffer.flip(); - System.out.println("收到服务器返回:"+new String(buffer.array(), 0, buffer.remaining())); - } - } catch (IOException e) { - throw new RuntimeException(e); - } -} -``` - -我们来看看效果: - -![image-20220504155104437](https://tva1.sinaimg.cn/large/e6c9d24egy1h1wf9h1v29j213w06m74t.jpg) - -![image-20220504155116276](https://tva1.sinaimg.cn/large/e6c9d24egy1h1wf9ms12cj217005uwet.jpg) - -可以看到成功实现了,当然各位也可以跟自己的室友一起开客户端进行测试,现在,我们只用了一个线程,就能够同时处理多个请求,可见多路复用是多么重要。 - -### 实现Reactor模式 - -前面我们简单实现了多路复用网络通信,我们接着来了解一下Reactor模式,对我们的服务端进行优化。 - -现在我们来看看如何进行优化,我们首先抽象出两个组件,Reactor线程和Handler处理器: - -* Reactor线程:负责响应IO事件,并分发到Handler处理器。新的事件包含连接建立就绪、读就绪、写就绪等。 -* Handler处理器:执行非阻塞的操作。 - -实际上我们之前编写的算是一种单线程Reactor的朴素模型(面向过程的写法),我们来看看标准的写法: - -![image-20220504163417826](https://tva1.sinaimg.cn/large/e6c9d24egy1h1wgietqmhj21fq0c6tah.jpg) - -客户端还是按照我们上面的方式连接到Reactor,并通过选择器走到Acceptor或是Handler,Acceptor主要负责客户端连接的建立,Handler负责读写操作,代码如下,首先是Handler: - -```java -public class Handler implements Runnable{ - - private final SocketChannel channel; - - public Handler(SocketChannel channel) { - this.channel = channel; - } - - @Override - public void run() { - try { - ByteBuffer buffer = ByteBuffer.allocate(128); - channel.read(buffer); - buffer.flip(); - System.out.println("接收到客户端数据:"+new String(buffer.array(), 0, buffer.remaining())); - channel.write(ByteBuffer.wrap("已收到!".getBytes())); - }catch (IOException e){ - e.printStackTrace(); - } - } -} -``` - -接着是Acceptor,实际上就是把上面的业务代码搬个位置罢了: - -```java -/** - * Acceptor主要用于处理连接操作 - */ -public class Acceptor implements Runnable{ - - private final ServerSocketChannel serverChannel; - private final Selector selector; - - public Acceptor(ServerSocketChannel serverChannel, Selector selector) { - this.serverChannel = serverChannel; - this.selector = selector; - } - - @Override - public void run() { - try{ - SocketChannel channel = serverChannel.accept(); - System.out.println("客户端已连接,IP地址为:"+channel.getRemoteAddress()); - channel.configureBlocking(false); - //这里在注册时,创建好对应的Handler,这样在Reactor中分发的时候就可以直接调用Handler了 - channel.register(selector, SelectionKey.OP_READ, new Handler(channel)); - }catch (IOException e){ - e.printStackTrace(); - } - } -} -``` - -这里我们在注册时丢了一个附加对象进去,这个附加对象会在选择器选择到此通道上时,可以通过`attachment()`方法进行获取,对于我们简化代码有大作用,一会展示,我们接着来看看Reactor: - -```java -public class Reactor implements Closeable, Runnable{ - - private final ServerSocketChannel serverChannel; - private final Selector selector; - public Reactor() throws IOException{ - serverChannel = ServerSocketChannel.open(); - selector = Selector.open(); - } - - @Override - public void run() { - try { - serverChannel.bind(new InetSocketAddress(8080)); - serverChannel.configureBlocking(false); - //注册时,将Acceptor作为附加对象存放,当选择器选择后也可以获取到 - serverChannel.register(selector, SelectionKey.OP_ACCEPT, new Acceptor(serverChannel, selector)); - while (true) { - int count = selector.select(); - System.out.println("监听到 "+count+" 个事件"); - Set selectionKeys = selector.selectedKeys(); - Iterator iterator = selectionKeys.iterator(); - while (iterator.hasNext()) { - this.dispatch(iterator.next()); //通过dispatch方法进行分发 - iterator.remove(); - } - } - }catch (IOException e) { - e.printStackTrace(); - } - } - - //通过此方法进行分发 - private void dispatch(SelectionKey key){ - Object att = key.attachment(); //获取attachment,ServerSocketChannel和对应的客户端Channel都添加了的 - if(att instanceof Runnable) { - ((Runnable) att).run(); //由于Handler和Acceptor都实现自Runnable接口,这里就统一调用一下 - } //这样就实现了对应的时候调用对应的Handler或是Acceptor了 - } - - //用了记得关,保持好习惯,就像看完视频要三连一样 - @Override - public void close() throws IOException { - serverChannel.close(); - selector.close(); - } -} -``` - -最后我们编写一下主类: - -```java -public static void main(String[] args) { - //创建Reactor对象,启动,完事 - try (Reactor reactor = new Reactor()){ - reactor.run(); - }catch (IOException e) { - e.printStackTrace(); - } -} -``` - -这样,我们就实现了单线程Reactor模式,注意全程使用到的都只是一个线程,没有创建新的线程来处理任何事情。 - -但是单线程始终没办法应对大量的请求,如果请求量上去了,单线程还是很不够用,接着我们来看看多线程Reactor模式,它创建了多个线程处理,我们可以将数据读取完成之后的操作交给线程池来执行: - -![image-20220504171307721](https://tva1.sinaimg.cn/large/e6c9d24egy1h1whmt5w1sj21fq0cmac7.jpg) - -其实我们只需要稍微修改一下Handler就行了: - -```java -public class Handler implements Runnable{ - //把线程池给安排了,10个线程 - private static final ExecutorService POOL = Executors.newFixedThreadPool(10); - private final SocketChannel channel; - public Handler(SocketChannel channel) { - this.channel = channel; - } - - @Override - public void run() { - try { - ByteBuffer buffer = ByteBuffer.allocate(1024); - channel.read(buffer); - buffer.flip(); - POOL.submit(() -> { - try { - System.out.println("接收到客户端数据:"+new String(buffer.array(), 0, buffer.remaining())); - channel.write(ByteBuffer.wrap("已收到!".getBytes())); - }catch (IOException e){ - e.printStackTrace(); - } - }); - } catch (IOException e) { - throw new RuntimeException(e); - } - } -} -``` - -这样,在数据读出之后,就可以将数据处理交给线程池执行。 - -但是这样感觉还是划分的不够,一个Reactor需要同时处理来自客户端的所有操作请求,显得有些乏力,那么不妨我们将Reactor做成一主多从的模式,让主Reactor只负责Accept操作,而其他的Reactor进行各自的其他操作: - -![image-20220505131410997](https://tva1.sinaimg.cn/large/e6c9d24egy1h1xgciyet7j21f40cijtp.jpg) - -现在我们来重新设计一下我们的代码,Reactor类就作为主节点,不进行任何修改,我们来修改一下其他的: - -```java -//SubReactor作为从Reactor -public class SubReactor implements Runnable, Closeable { - //每个从Reactor也有一个Selector - private final Selector selector; - - //创建一个4线程的线程池,也就是四个从Reactor工作 - private static final ExecutorService POOL = Executors.newFixedThreadPool(4); - private static final SubReactor[] reactors = new SubReactor[4]; - private static int selectedIndex = 0; //采用轮询机制,每接受一个新的连接,就轮询分配给四个从Reactor - static { //在一开始的时候就让4个从Reactor跑起来 - for (int i = 0; i < 4; i++) { - try { - reactors[i] = new SubReactor(); - POOL.submit(reactors[i]); - } catch (IOException e) { - e.printStackTrace(); - } - } - } - //轮询获取下一个Selector(Acceptor用) - public static Selector nextSelector(){ - Selector selector = reactors[selectedIndex].selector; - selectedIndex = (selectedIndex + 1) % 4; - return selector; - } - - private SubReactor() throws IOException { - selector = Selector.open(); - } - - @Override - public void run() { - try { //启动后直接等待selector监听到对应的事件即可,其他的操作逻辑和Reactor一致 - while (true) { - int count = selector.select(); - System.out.println(Thread.currentThread().getName()+" >> 监听到 "+count+" 个事件"); - Set selectionKeys = selector.selectedKeys(); - Iterator iterator = selectionKeys.iterator(); - while (iterator.hasNext()) { - this.dispatch(iterator.next()); - iterator.remove(); - } - } - }catch (IOException e) { - e.printStackTrace(); - } - } - - private void dispatch(SelectionKey key){ - Object att = key.attachment(); - if(att instanceof Runnable) { - ((Runnable) att).run(); - } - } - - @Override - public void close() throws IOException { - selector.close(); - } -} -``` - -我们接着来修改一下Acceptor类: - -```java -public class Acceptor implements Runnable{ - - private final ServerSocketChannel serverChannel; //只需要一个ServerSocketChannel就行了 - - public Acceptor(ServerSocketChannel serverChannel) { - this.serverChannel = serverChannel; - } - - @Override - public void run() { - try{ - SocketChannel channel = serverChannel.accept(); //还是正常进行Accept操作,得到SocketChannel - System.out.println(Thread.currentThread().getName()+" >> 客户端已连接,IP地址为:"+channel.getRemoteAddress()); - channel.configureBlocking(false); - Selector selector = SubReactor.nextSelector(); //选取下一个从Reactor的Selector - selector.wakeup(); //在注册之前唤醒一下防止卡死 - channel.register(selector, SelectionKey.OP_READ, new Handler(channel)); //注意现在注册的是从Reactor的Selector - }catch (IOException e){ - e.printStackTrace(); - } - } -} -``` - -现在,SocketChannel相关的操作就由从Reactor进行处理了,而不是一律交给主Reactor进行操作。 - -至此,我们已经了解了NIO的三大组件:*Buffer、Channel、Selector*,有关NIO基础相关的内容,就讲解到这里。下一章我们将继续讲解基于NIO实现的高性能网络通信框架Netty。 \ No newline at end of file diff --git a/青空笔记/NIO笔记/Java NIO笔记(二).md b/青空笔记/NIO笔记/Java NIO笔记(二).md deleted file mode 100644 index 6065e34..0000000 --- a/青空笔记/NIO笔记/Java NIO笔记(二).md +++ /dev/null @@ -1,1950 +0,0 @@ -# Netty框架 - -前面我们学习了Java为我们提供的NIO框架,提供使用NIO提供的三大组件,我们就可以编写更加高性能的客户端/服务端网络程序了,甚至还可以自行规定一种通信协议进行通信。 - -## NIO框架存在的问题 - -但是之前我们在使用NIO框架的时候,还是发现了一些问题,我们先来盘点一下。 - -### 客户端关闭导致服务端空轮询 - -可能在之前的实验中,你发现了这样一个问题: - -![image-20220506214320210](https://tva1.sinaimg.cn/large/e6c9d24egy1h1z0omdbn1j21q60isq70.jpg) - -当我们的客户端主动与服务端断开连接时,会导致READ事件一直被触发,也就是说`selector.select()`会直接通过,并且是可读的状态,但是我们发现实际上读到是数据是一个空的(上面的图中在空轮询两次后抛出异常了,也有可能是无限的循环下去)所以这里我们得稍微处理一下: - -```java -} else if(key.isReadable()) { - SocketChannel channel = (SocketChannel) key.channel(); - ByteBuffer buffer = ByteBuffer.allocate(128); - //这里我们需要判断一下,如果read操作得到的结果是-1,那么说明服务端已经断开连接了 - if(channel.read(buffer) < 0) { - System.out.println("客户端已经断开连接了:"+channel.getRemoteAddress()); - channel.close(); //直接关闭此通道 - continue; //继续进行选择 - } - buffer.flip(); - System.out.println("接收到客户端数据:"+new String(buffer.array(), 0, buffer.remaining())); - channel.write(ByteBuffer.wrap("已收到!".getBytes())); -} -``` - -这样,我们就可以在客户端主动断开时关闭连接了: - -![image-20220506222006550](https://tva1.sinaimg.cn/large/e6c9d24egy1h1z1qtxd9nj21cy078jrw.jpg) - -当然,除了这种情况可能会导致空轮询之外,实际上还有一种可能,这种情况是NIO框架本身的BUG: - -```java -while (true) { - int count = selector.select(); //由于底层epoll机制的问题,导致select方法可能会一直返回0,造成无限循环的情况。 - System.out.println("监听到 "+count+" 个事件"); - Set selectionKeys = selector.selectedKeys(); - Iterator iterator = selectionKeys.iterator(); -``` - -详细请看JDK官方BUG反馈: - -1. [JDK-6670302 : (se) NIO selector wakes up with 0 selected keys infinitely](https://link.jianshu.com/?t=http%3A%2F%2Fbugs.java.com%2Fbugdatabase%2Fview_bug.do%3Fbug_id%3D6670302) -2. [JDK-6403933 : (se) Selector doesn't block on Selector.select(timeout) (lnx)](https://link.jianshu.com/?t=http%3A%2F%2Fbugs.java.com%2Fbugdatabase%2Fview_bug.do%3Fbug_id%3D6403933) - -本质原因也是因为客户端的主动断开导致: - ->This is an issue with poll (and epoll) on Linux. If a file descriptor for a connected socket is polled with a request event mask of 0, and if the connection is abruptly terminated (RST) then the poll wakes up with the POLLHUP (and maybe POLLERR) bit set in the returned event set. The implication of this behaviour is that Selector will wakeup and as the interest set for the SocketChannel is 0 it means there aren't any selected events and the select method returns 0. - -这个问题本质是与操作系统有关的,所以JDK一直都认为是操作系统的问题,不应该由自己来处理,所以这个问题在当时的好几个JDK版本都是存在的,这是一个很严重的空转问题,无限制地进行空转操作会导致CPU资源被疯狂消耗。 - -不过,这个问题,却被Netty框架巧妙解决了,我们后面再说。 - -### 粘包/拆包问题 - -除了上面的问题之外,我们接着来看下一个问题。 - -我们在`计算机网络`这门课程中学习过,操作系统通过TCP协议发送数据的时候,也会先将数据存放在缓冲区中,而至于什么时候真正地发出这些数据,是由TCP协议来决定的,这是我们无法控制的事情。 - -![image-20220506224926169](https://tva1.sinaimg.cn/large/e6c9d24egy1h1z2lccfu5j21ii0c60tu.jpg) - -也就是说,比如现在我们要发送两个数据包(P1/P2),理想情况下,这两个包应该是依次到达服务端,并由服务端正确读取两次数据出来,但是由于上面的机制,可能会出现下面的情况: - -1. 可能P1和P2被合在一起发送给了服务端(粘包现象) -2. 可能P1和P2的前半部分合在一起发送给了服务端(拆包现象) -3. 可能P1的前半部分就被单独作为一个部分发给了服务端,后面的和P2一起发给服务端(也是拆包现象) - -![image-20220506224258538](https://tva1.sinaimg.cn/large/e6c9d24egy1h1z2em84c6j21cm0da3z4.jpg) - -当然,对于这种问题,也有一些比较常见的解决方案: - -1. 消息定长,发送方和接收方规定固定大小的消息长度,例如每个数据包大小固定为200字节,如果不够,空位补空格,只有接收了200个字节之后,作为一个完整的数据包进行处理。 -2. 在每个包的末尾使用固定的分隔符,比如每个数据包末尾都是`\r\n`,这样就一定需要读取到这样的分隔符才能将前面所有的数据作为一个完整的数据包进行处理。 -3. 将消息分为头部和本体,在头部中保存有当前整个数据包的长度,只有在读到足够长度之后才算是读到了一个完整的数据包。 - -这里我们就来演示一下第一种解决方案: - -```java -public static void main(String[] args) { - try (ServerSocketChannel serverChannel = ServerSocketChannel.open(); - Selector selector = Selector.open()){ - serverChannel.bind(new InetSocketAddress(8080)); - serverChannel.configureBlocking(false); - serverChannel.register(selector, SelectionKey.OP_ACCEPT); - - //一个数据包要求必须塞满30个字节 - ByteBuffer buffer = ByteBuffer.allocate(30); - - while (true) { - int count = selector.select(); - Set selectionKeys = selector.selectedKeys(); - Iterator iterator = selectionKeys.iterator(); - while (iterator.hasNext()) { - ... - if(buffer.remaining() == 0) { - buffer.flip(); - System.out.println("接收到客户端数据:"+new String(buffer.array(), 0, buffer.remaining())); - buffer.clear(); - } - channel.write(ByteBuffer.wrap(("已收到 "+size+" 字节的数据!").getBytes())); - } - ... -``` - -现在,当我们的客户端发送消息时,如果没有达到30个字节,那么会暂时存储起来,等有30个之后再一次性得到,当然如果数据量超过了30,那么最多也只会读取30个字节,其他的放在下一批: - -![image-20220507102955570](https://tva1.sinaimg.cn/large/e6c9d24egy1h1zmuamek3j21ou0hmdiq.jpg) - -![image-20220507103009255](https://tva1.sinaimg.cn/large/e6c9d24egy1h1zmugj9ztj21l005qt9d.jpg) - -这样就可以在一定程度上解决粘包/拆包问题了。 - -*** - -## 走进Netty框架 - -前面我们盘点了一下NIO存在的一些问题,而在Netty框架中,这些问题都被巧妙的解决了。 - -Netty是由JBOSS提供的一个开源的java网络编程框架,主要是对java的nio包进行了再次封装。Netty比java原生的nio包提供了更加强大、稳定的功能和易于使用的api。 netty的作者是Trustin Lee,这是一个韩国人,他还开发了另外一个著名的网络编程框架,mina。二者在很多方面都十分相似,它们的线程模型也是基本一致 。不过netty社区的活跃程度要mina高得多。 - -Netty实际上应用场景非常多,比如我们的Minecraft游戏服务器: - -![image-20220507110120090](https://tva1.sinaimg.cn/large/e6c9d24egy1h1znqwr5lqj21wa0pak1k.jpg) - -Java版本的Minecraft服务器就是使用Netty框架作为网络通信的基础,正是得益于Netty框架的高性能,我们才能愉快地和其他的小伙伴一起在服务器里面炸服。 - -学习了Netty框架后,说不定你也可以摸索到部分Minecraft插件/模组开发的底层细节(太折磨了,UP主高中搞了大半年这玩意) - -当然除了游戏服务器之外,我们微服务之间的远程调用也可以使用Netty来完成,比如Dubbo的RPC框架,包括最新的SpringWebFlux框架,也抛弃了内嵌Tomcat而使用Netty作为通信框架。既然Netty这么强大,那么现在我们就开始Netty的学习吧! - -导包先: - -```xml - - - io.netty - netty-all - 4.1.76.Final - - -``` - -### ByteBuf介绍 - -Netty并没有使用NIO中提供的ByteBuffer来进行数据装载,而是自行定义了一个ByteBuf类。 - -那么这个类相比NIO中的ByteBuffer有什么不同之处呢? - -* 写操作完成后无需进行`flip()`翻转。 -* 具有比ByteBuffer更快的响应速度。 -* 动态扩容。 - -首先我们来看看它的内部结构: - -```java -public abstract class AbstractByteBuf extends ByteBuf { - ... - int readerIndex; //index被分为了读和写,是两个指针在同时工作 - int writerIndex; - private int markedReaderIndex; //mark操作也分两种 - private int markedWriterIndex; - private int maxCapacity; //最大容量,没错,这玩意能动态扩容 -``` - -可以看到,读操作和写操作分别由两个指针在进行维护,每写入一次,`writerIndex`向后移动一位,每读取一次,也是`readerIndex`向后移动一位,当然`readerIndex`不能大于`writerIndex`,这样就不会像NIO中的ByteBuffer那样还需要进行翻转了。 - -![image-20220507160235552](https://tva1.sinaimg.cn/large/e6c9d24egy1h1zwgc0v5ej21fe08m3z1.jpg) - -其中`readerIndex`和`writerIndex`之间的部分就是是可读的内容,而`writerIndex`之后到`capacity`都是可写的部分。 - -我们来实际使用一下看看: - -```java -public static void main(String[] args) { - //创建一个初始容量为10的ByteBuf缓冲区,这里的Unpooled是用于快速生成ByteBuf的工具类 - //至于为啥叫Unpooled是池化的意思,ByteBuf有池化和非池化两种,区别在于对内存的复用,我们之后再讨论 - ByteBuf buf = Unpooled.buffer(10); - System.out.println("初始状态:"+Arrays.toString(buf.array())); - buf.writeInt(-888888888); //写入一个Int数据 - System.out.println("写入Int后:"+Arrays.toString(buf.array())); - buf.readShort(); //无需翻转,直接读取一个short数据出来 - System.out.println("读取Short后:"+Arrays.toString(buf.array())); - buf.discardReadBytes(); //丢弃操作,会将当前的可读部分内容丢到最前面,并且读写指针向前移动丢弃的距离 - System.out.println("丢弃之后:"+Arrays.toString(buf.array())); - buf.clear(); //清空操作,清空之后读写指针都归零 - System.out.println("清空之后:"+Arrays.toString(buf.array())); -} -``` - -通过结合断点调试,我们可以观察读写指针的移动情况,更加清楚的认识一下ByteBuf的底层操作。 - -我们再来看看划分操作是不是和之前一样的: - -```java -public static void main(String[] args) { - //我们也可以将一个byte[]直接包装进缓冲区(和NIO是一样的)不过写指针的值一开始就跑到最后去了,但是这玩意是不是只读的 - ByteBuf buf = Unpooled.wrappedBuffer("abcdefg".getBytes()); - //除了包装,也可以复制数据,copiedBuffer()会完完整整将数据拷贝到一个新的缓冲区中 - buf.readByte(); //读取一个字节 - ByteBuf slice = buf.slice(); //现在读指针位于1,然后进行划分 - - System.out.println(slice.arrayOffset()); //得到划分出来的ByteBuf的偏移地址 - System.out.println(Arrays.toString(slice.array())); -} -``` - -可以看到,划分也是根据当前读取的位置来进行的。 - -我们继续来看看它的另一个特性,动态扩容,比如我们申请一个容量为10的缓冲区: - -```java -public static void main(String[] args) { - ByteBuf buf = Unpooled.buffer(10); //容量只有10字节 - System.out.println(buf.capacity()); - //直接写一个字符串 - buf.writeCharSequence("卢本伟牛逼!", StandardCharsets.UTF_8); //很明显这么多字已经超过10字节了 - System.out.println(buf.capacity()); -} -``` - -通过结果我们发现,在写入一个超出当前容量的数据时,会进行动态扩容,扩容会从64开始,之后每次触发扩容都会x2,当然如果我们不希望它扩容,可以指定最大容量: - -```java -public static void main(String[] args) { - //在生成时指定maxCapacity也为10 - ByteBuf buf = Unpooled.buffer(10, 10); - System.out.println(buf.capacity()); - buf.writeCharSequence("卢本伟牛逼!", StandardCharsets.UTF_8); - System.out.println(buf.capacity()); -} -``` - -可以看到现在无法再动态扩容了: - -![image-20220507165153381](https://tva1.sinaimg.cn/large/e6c9d24egy1h1zxvmzz7hj224806sq64.jpg) - -我们接着来看一下缓冲区的三种实现模式:堆缓冲区模式、直接缓冲区模式、复合缓冲区模式。 - -堆缓冲区(数组实现)和直接缓冲区(堆外内存实现)不用多说,前面我们在NIO中已经了解过了,我们要创建一个直接缓冲区也很简单,直接调用: - -```java -public static void main(String[] args) { - ByteBuf buf = Unpooled.directBuffer(10); - System.out.println(Arrays.toString(buf.array())); -} -``` - -同样的不能直接拿到数组,因为底层压根不是数组实现的: - -![image-20220507163253662](https://tva1.sinaimg.cn/large/e6c9d24egy1h1zxbvl2y3j21h803st9z.jpg) - -我们来看看复合模式,复合模式可以任意地拼凑组合其他缓冲区,比如我们可以: - -![image-20220507171216323](https://tva1.sinaimg.cn/large/e6c9d24egy1h1zygujz9sj21no0c0abg.jpg) - -这样,如果我们想要对两个缓冲区组合的内容进行操作,我们就不用再单独创建一个新的缓冲区了,而是直接将其进行拼接操作,相当于是作为多个缓冲区组合的视图。 - -```java -//创建一个复合缓冲区 -CompositeByteBuf buf = Unpooled.compositeBuffer(); -buf.addComponent(Unpooled.copiedBuffer("abc".getBytes())); -buf.addComponent(Unpooled.copiedBuffer("def".getBytes())); - -for (int i = 0; i < buf.capacity(); i++) { - System.out.println((char) buf.getByte(i)); -} -``` - -可以看到我们也可以正常操作组合后的缓冲区。 - -最后我们来看看,池化缓冲区和非池化缓冲区的区别。 - -我们研究一下Unpooled工具类中具体是如何创建buffer的: - -```java -public final class Unpooled { - private static final ByteBufAllocator ALLOC; //实际上内部是有一个ByteBufAllocator对象的 - public static final ByteOrder BIG_ENDIAN; - public static final ByteOrder LITTLE_ENDIAN; - public static final ByteBuf EMPTY_BUFFER; - - public static ByteBuf buffer() { - return ALLOC.heapBuffer(); //缓冲区的创建操作实际上是依靠ByteBufAllocator来进行的 - } - - ... - - static { //ALLOC在静态代码块中进行指定,实际上真正的实现类是UnpooledByteBufAllocator - ALLOC = UnpooledByteBufAllocator.DEFAULT; - BIG_ENDIAN = ByteOrder.BIG_ENDIAN; - LITTLE_ENDIAN = ByteOrder.LITTLE_ENDIAN; - EMPTY_BUFFER = ALLOC.buffer(0, 0); //空缓冲区容量和最大容量都是0 - - assert EMPTY_BUFFER instanceof EmptyByteBuf : "EMPTY_BUFFER must be an EmptyByteBuf."; - - } -} -``` - -那么我们来看看,这个ByteBufAllocator又是个啥,顾名思义,其实就是负责分配缓冲区的。 - -它有两个具体实现类:`UnpooledByteBufAllocator`和`PooledByteBufAllocator`,一个是非池化缓冲区生成器,还有一个是池化缓冲区生成器,那么池化和非池化有啥区别呢? - -实际上池化缓冲区利用了池化思想,将缓冲区通过设置内存池来进行内存块复用,这样就不用频繁地进行内存的申请,尤其是在使用堆外内存的时候,避免多次重复通过底层`malloc()`函数系统调用申请内存造成的性能损失。Netty的内存管理机制主要是借鉴Jemalloc内存分配策略,感兴趣的小伙伴可以深入了解一下。 - -所以,由于是复用内存空间,我们来看个例子: - -```java -public static void main(String[] args) { - ByteBufAllocator allocator = PooledByteBufAllocator.DEFAULT; - ByteBuf buf = allocator.directBuffer(10); //申请一个容量为10的直接缓冲区 - buf.writeChar('T'); //随便操作操作 - System.out.println(buf.readChar()); - buf.release(); //释放此缓冲区 - - ByteBuf buf2 = allocator.directBuffer(10); //重新再申请一个同样大小的直接缓冲区 - System.out.println(buf2 == buf); -} -``` - -可以看到,在我们使用完一个缓冲区之后,我们将其进行资源释放,当我们再次申请一个同样大小的缓冲区时,会直接得到之前已经申请好的缓冲区,所以,PooledByteBufAllocator实际上是将ByteBuf实例放入池中在进行复用。 - -### 零拷贝简介 - -**注意:**此小节作为选学内容,需要掌握`操作系统`和`计算机组成原理`才能学习。 - -零拷贝是一种I/O操作优化技术,可以快速高效地将数据从文件系统移动到网络接口,而不需要将其从内核空间复制到用户空间,首先第一个问题,什么是内核空间,什么又是用户空间呢? - -其实早期操作系统是不区分内核空间和用户空间的,但是应用程序能访问任意内存空间,程序很容易不稳定,常常把系统搞崩溃,比如清除操作系统的内存数据。实际上让应用程序随便访问内存真的太危险了,于是就按照CPU 指令的重要程度对指令进行了分级,指令分为四个级别:Ring0 ~ Ring3,Linux 下只使用了 Ring0 和 Ring3 两个运行级别,进程运行在 Ring3 级别时运行在用户态,指令只访问用户空间,而运行在 Ring0 级别时被称为运行在内核态,可以访问任意内存空间。 - -![image-20220512122211805](https://tva1.sinaimg.cn/large/e6c9d24egy1h25i6lqr4hj21l80gct9n.jpg) - -比如我们Java中创建一个新的线程,实际上最终是要交给操作系统来为我们进行分配的,而需要操作系统帮助我们完成任务则需要进行系统调用,是内核在进行处理,不是我们自己的程序在处理,这时就相当于我们的程序处于了内核态,而当操作系统底层分配完成,最后到我们Java代码中返回得到线程对象时,又继续由我们的程序进行操作,所以从内核态转换回了用户态。 - -而我们的文件操作也是这样,我们实际上也是需要让操作系统帮助我们从磁盘上读取文件数据或是向网络发送数据,比如使用传统IO的情况下,我们要从磁盘上读取文件然后发送到网络上,就会经历以下流程: - -![image-20220512123113806](https://tva1.sinaimg.cn/large/e6c9d24egy1h25ify7168j21s60i4n0f.jpg) - -可以看到整个过程中是经历了2次CPU拷贝+2次DMA拷贝,一共四次拷贝,虽然逻辑比较清晰,但是数据老是这样来回进行复制,是不是太浪费时间了点?所以我们就需要寻找一种更好的方式,来实现零拷贝。 - -实现零拷贝我们这里演示三种方案: - -1. 使用虚拟内存 - - 现在的操作系统基本都是支持虚拟内存的,我们可以让内核空间和用户空间的虚拟地址指向同一个物理地址,这样就相当于是直接共用了这一块区域,也就谈不上拷贝操作了: - - ![image-20220512124512936](https://tva1.sinaimg.cn/large/e6c9d24egy1h25iui62e8j21na0i477f.jpg) - -2. 使用mmap/write内存映射 - - 实际上这种方式就是将内核空间中的缓存直接映射到用户空间缓存,比如我们之前在学习NIO中使用的MappedByteBuffer,就是直接作为映射存在,当我们需要将数据发送到Socket缓冲区时,直接在内核空间中进行操作就行了: - - ![image-20220512124732995](https://tva1.sinaimg.cn/large/e6c9d24egy1h25iwxop2wj21ky0i0ad1.jpg) - - 不过这样还是会出现用户态和内核态的切换,我们得再优化优化。 - -3. 使用sendfile方式 - - 在Linux2.1开始,引入了sendfile方式来简化操作,我们可以直接告诉内核要把哪个文件数据拷贝拷贝到Socket上,直接在内核空间中一步到位: - - ![image-20220512124950007](https://tva1.sinaimg.cn/large/e6c9d24egy1h25izbhxnrj21f60hwtb0.jpg) - - 比如我们之前在NIO中使用的`transferTo()`方法,就是利用了这种机制来实现零拷贝的。 - -### Netty工作模型 - -前面我们了解了Netty为我们提供的更高级的缓冲区类,我们接着来看看Netty是如何工作的,上一章我们介绍了Reactor模式,而Netty正是以主从Reactor多线程模型为基础,构建出了一套高效的工作模型。 - -大致工作模型图如下: - -![image-20220509215109408](https://tva1.sinaimg.cn/large/e6c9d24egy1h22hrmmll1j21hg0c040d.jpg) - -可以看到,和我们之前介绍的主从Reactor多线程模型非常类似: - -![image-20220505131410997](https://tva1.sinaimg.cn/large/e6c9d24egy1h1xgciyet7j21f40cijtp.jpg) - -所有的客户端需要连接到主Reactor完成Accept操作后,其他的操作由从Reactor去完成,这里也是差不多的思想,但是它进行了一些改进,我们来看一下它的设计: - -* Netty 抽象出两组线程池BossGroup和WorkerGroup,BossGroup专门负责接受客户端的连接, WorkerGroup专门负读写,就像我们前面说的主从Reactor一样。 -* 无论是BossGroup还是WorkerGroup,都是使用EventLoop(事件循环,很多系统都采用了事件循环机制,比如前端框架Node.js,事件循环顾名思义,就是一个循环,不断地进行事件通知)来进行事件监听的,整个Netty也是使用事件驱动来运作的,比如当客户端已经准备好读写、连接建立时,都会进行事件通知,说白了就像我们之前写NIO多路复用那样,只不过这里换成EventLoop了而已,它已经帮助我们封装好了一些常用操作,而且我们可以自己添加一些额外的任务,如果有多个EventLoop,会存放在EventLoopGroup中,EventLoopGroup就是BossGroup和WorkerGroup的具体实现。 -* 在BossGroup之后,会正常将SocketChannel绑定到WorkerGroup中的其中一个EventLoop上,进行后续的读写操作监听。 - -前面我们大致了解了一下Netty的工作模型,接着我们来尝试创建一个Netty服务器: - -```java -public static void main(String[] args) { - //这里我们使用NioEventLoopGroup实现类即可,创建BossGroup和WorkerGroup - //当然还有EpollEventLoopGroup,但是仅支持Linux,这是Netty基于Linux底层Epoll单独编写的一套本地实现,没有使用NIO那套 - EventLoopGroup bossGroup = new NioEventLoopGroup(), workerGroup = new NioEventLoopGroup(); - - //创建服务端启动引导类 - ServerBootstrap bootstrap = new ServerBootstrap(); - //可链式,就很棒 - bootstrap - .group(bossGroup, workerGroup) //指定事件循环组 - .channel(NioServerSocketChannel.class) //指定为NIO的ServerSocketChannel - .childHandler(new ChannelInitializer() { //注意,这里的SocketChannel不是我们NIO里面的,是Netty的 - @Override - protected void initChannel(SocketChannel channel) { - //获取流水线,当我们需要处理客户端的数据时,实际上是像流水线一样在处理,这个流水线上可以有很多Handler - channel.pipeline().addLast(new ChannelInboundHandlerAdapter(){ //添加一个Handler,这里使用ChannelInboundHandlerAdapter - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) { //ctx是上下文,msg是收到的消息,默认以ByteBuf形式(也可以是其他形式,后面再说) - ByteBuf buf = (ByteBuf) msg; //类型转换一下 - System.out.println(Thread.currentThread().getName()+" >> 接收到客户端发送的数据:"+buf.toString(StandardCharsets.UTF_8)); - //通过上下文可以直接发送数据回去,注意要writeAndFlush才能让客户端立即收到 - ctx.writeAndFlush(Unpooled.wrappedBuffer("已收到!".getBytes())); - } - }); - } - }); - //最后绑定端口,启动 - bootstrap.bind(8080); -} -``` - -可以看到上面写了很多东西,但是你一定会懵逼,这些新来的东西,都是什么跟什么啊,怎么一个也没看明白?没关系,我们可以暂时先将代码写在这里,具体的各个部分,还请听后面细细道来。 - -我们接着编写一个客户端,客户端可以直接使用我们之前的: - -```java -public static void main(String[] args) { - //创建一个新的SocketChannel,一会通过通道进行通信 - try (SocketChannel channel = SocketChannel.open(new InetSocketAddress("localhost", 8080)); - Scanner scanner = new Scanner(System.in)){ - System.out.println("已连接到服务端!"); - while (true) { //咱给它套个无限循环,这样就能一直发消息了 - System.out.println("请输入要发送给服务端的内容:"); - String text = scanner.nextLine(); - if(text.isEmpty()) continue; - //直接向通道中写入数据,真舒服 - channel.write(ByteBuffer.wrap(text.getBytes())); - System.out.println("已发送!"); - ByteBuffer buffer = ByteBuffer.allocate(128); - channel.read(buffer); //直接从通道中读取数据 - buffer.flip(); - System.out.println("收到服务器返回:"+new String(buffer.array(), 0, buffer.remaining())); - } - } catch (IOException e) { - throw new RuntimeException(e); - } -} -``` - -通过通道正常收发数据即可,这样我们就成功搭建好了一个Netty服务器。 - -### Channel详解 - -在学习NIO时,我们就已经接触到Channel了,我们可以通过通道来进行数据的传输,并且通道支持双向传输。 - -而在Netty中,也有对应的Channel类型: - -```java -public interface Channel extends AttributeMap, ChannelOutboundInvoker, Comparable { - ChannelId id(); //通道ID - EventLoop eventLoop(); //获取此通道所属的EventLoop,因为一个Channel在它的生命周期内只能注册到一个EventLoop中 - Channel parent(); //Channel是具有层级关系的,这里是返回父Channel - ChannelConfig config(); - boolean isOpen(); //通道当前的相关状态 - boolean isRegistered(); - boolean isActive(); - ChannelMetadata metadata(); //通道相关信息 - SocketAddress localAddress(); - SocketAddress remoteAddress(); - ChannelFuture closeFuture(); //关闭通道,但是会用到ChannelFuture,后面说 - boolean isWritable(); - long bytesBeforeUnwritable(); - long bytesBeforeWritable(); - Unsafe unsafe(); - ChannelPipeline pipeline(); //流水线,之后也会说 - ByteBufAllocator alloc(); //可以直接从Channel拿到ByteBufAllocator的实例,来分配ByteBuf - Channel read(); - Channel flush(); //刷新,基操 -} -``` - -可以看到,Netty中的Channel相比NIO功能就多得多了。Netty中的Channel主要特点如下: - -* 所有的IO操作都是异步的,并不是在当前线程同步运行,方法调用之后就直接返回了,那怎么获取操作的结果呢?还记得我们在前面JUC篇教程中学习的Future吗,没错,这里的ChannelFuture也是干这事的。 - -我们可以来看一下Channel接口的父接口ChannelOutboundInvoker接口,这里面定义了大量的I/O操作: - -```java -public interface ChannelOutboundInvoker { //通道出站调用(包含大量的网络出站操作,比如写) - ChannelFuture bind(SocketAddress var1); //Socket绑定、连接、断开、关闭等操作 - ChannelFuture connect(SocketAddress var1); - ChannelFuture connect(SocketAddress var1, SocketAddress var2); - ChannelFuture disconnect(); - ChannelFuture close(); - ChannelFuture deregister(); - ChannelFuture bind(SocketAddress var1, ChannelPromise var2); //下面这一系列还有附带ChannelPromise的,ChannelPromise我们后面再说,其实就是ChannelFuture的增强版 - ChannelFuture connect(SocketAddress var1, ChannelPromise var2); - ChannelFuture connect(SocketAddress var1, SocketAddress var2, ChannelPromise var3); - ChannelFuture disconnect(ChannelPromise var1); - ChannelFuture close(ChannelPromise var1); - ChannelFuture deregister(ChannelPromise var1); - ChannelOutboundInvoker read(); - - ChannelFuture write(Object var1); //可以看到这些常见的写操作,都是返回的ChannelFuture,而不是直接给结果 - ChannelFuture write(Object var1, ChannelPromise var2); - ChannelOutboundInvoker flush(); - ChannelFuture writeAndFlush(Object var1, ChannelPromise var2); - ChannelFuture writeAndFlush(Object var1); - - ChannelPromise newPromise(); //其他的暂时不提 - ChannelProgressivePromise newProgressivePromise(); - ChannelFuture newSucceededFuture(); - ChannelFuture newFailedFuture(Throwable var1); - ChannelPromise voidPromise(); -} -``` - -当然它还实现了AttributeMap接口,其实有点类似于Session那种感觉,我们可以添加一些属性之类的: - -```java -public interface AttributeMap { - Attribute attr(AttributeKey var1); - - boolean hasAttr(AttributeKey var1); -} -``` - -我们了解了Netty底层的Channel之后,我们接着来看ChannelHandler,既然现在有了通道,那么怎么进行操作呢?我们可以将需要处理的事情放在ChannelHandler中,ChannelHandler充当了所有入站和出站数据的应用程序逻辑的容器,实际上就是我们之前Reactor模式中的Handler,全靠它来处理读写操作。 - -不过这里不仅仅是一个简单的ChannelHandler在进行处理,而是一整套流水线,我们之后会介绍ChannelPipeline。 - -比如我们上面就是使用了ChannelInboundHandlerAdapter抽象类,它是ChannelInboundHandler接口的实现,用于处理入站数据,可以看到我们实际上就是通过重写对应的方法来进行处理,这些方法会在合适的时间被调用: - -```java -channel.pipeline().addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) { - //ctx是上下文,msg是收到的消息,以ByteBuf形式 - ByteBuf buf = (ByteBuf) msg; //类型转换一下 - System.out.println(Thread.currentThread().getName()+" >> 接收到客户端发送的数据:"+buf.toString(StandardCharsets.UTF_8)); - //通过上下文可以直接发送数据回去,注意要writeAndFlush才能让客户端立即收到 - ctx.writeAndFlush(Unpooled.wrappedBuffer("已收到!".getBytes())); - } -}); -``` - -我们先从顶层接口开始看起: - -```java -public interface ChannelHandler { - //当ChannelHandler被添加到流水线中时调用 - void handlerAdded(ChannelHandlerContext var1) throws Exception; - //当ChannelHandler从流水线中移除时调用 - void handlerRemoved(ChannelHandlerContext var1) throws Exception; - - /** @deprecated 已过时那咱就不管了 */ - @Deprecated - void exceptionCaught(ChannelHandlerContext var1, Throwable var2) throws Exception; - - @Inherited - @Documented - @Target({ElementType.TYPE}) - @Retention(RetentionPolicy.RUNTIME) - public @interface Sharable { - } -} -``` - -顶层接口的定义比较简单,就只有一些流水线相关的回调方法,我们接着来看下一级: - -```java -//ChannelInboundHandler用于处理入站相关事件 -public interface ChannelInboundHandler extends ChannelHandler { - //当Channel已经注册到自己的EventLoop上时调用,前面我们说了,一个Channel只会注册到一个EventLoop上,注册到EventLoop后,这样才会在发生对应事件时被通知。 - void channelRegistered(ChannelHandlerContext var1) throws Exception; - //从EventLoop上取消注册时 - void channelUnregistered(ChannelHandlerContext var1) throws Exception; - //当Channel已经处于活跃状态时被调用,此时Channel已经连接/绑定,并且已经就绪 - void channelActive(ChannelHandlerContext var1) throws Exception; - //跟上面相反,不再活跃了,并且不在连接它的远程节点 - void channelInactive(ChannelHandlerContext var1) throws Exception; - //当从Channel读取数据时被调用,可以看到数据被自动包装成了一个Object(默认是ByteBuf) - void channelRead(ChannelHandlerContext var1, Object var2) throws Exception; - //上一个读取操作完成后调用 - void channelReadComplete(ChannelHandlerContext var1) throws Exception; - //暂时不介绍 - void userEventTriggered(ChannelHandlerContext var1, Object var2) throws Exception; - //当Channel的可写状态发生改变时被调用 - void channelWritabilityChanged(ChannelHandlerContext var1) throws Exception; - //出现异常时被调用 - void exceptionCaught(ChannelHandlerContext var1, Throwable var2) throws Exception; -} -``` - -而我们上面用到的ChannelInboundHandlerAdapter实际上就是对这些方法实现的抽象类,相比直接用接口,我们可以只重写我们需要的方法,没有重写的方法会默认向流水线下一个ChannelHandler发送。 - -我们来测试一下吧: - -```java -public class TestChannelHandler extends ChannelInboundHandlerAdapter { - - public void channelRegistered(ChannelHandlerContext ctx) throws Exception { - System.out.println("channelRegistered"); - } - - public void channelUnregistered(ChannelHandlerContext ctx) throws Exception { - System.out.println("channelUnregistered"); - } - - public void channelActive(ChannelHandlerContext ctx) throws Exception { - System.out.println("channelActive"); - } - - public void channelInactive(ChannelHandlerContext ctx) throws Exception { - System.out.println("channelInactive"); - } - - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - ByteBuf buf = (ByteBuf) msg; - System.out.println(Thread.currentThread().getName()+" >> 接收到客户端发送的数据:"+buf.toString(StandardCharsets.UTF_8)); - //这次我们就直接使用ctx.alloc()来生成缓冲区 - ByteBuf back = ctx.alloc().buffer(); - back.writeCharSequence("已收到!", StandardCharsets.UTF_8); - ctx.writeAndFlush(back); - System.out.println("channelRead"); - } - - public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { - System.out.println("channelReadComplete"); - } - - public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { - System.out.println("userEventTriggered"); - } - - public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception { - System.out.println("channelWritabilityChanged"); - } - - public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { - System.out.println("exceptionCaught"+cause); - } -} -``` - -```java -public static void main(String[] args) { - EventLoopGroup bossGroup = new NioEventLoopGroup(), workerGroup = new NioEventLoopGroup(); - ServerBootstrap bootstrap = new ServerBootstrap(); - bootstrap - .group(bossGroup, workerGroup) - .channel(NioServerSocketChannel.class) - //ChannelInitializer是一个特殊的ChannelHandler,它本身不处理任何出站/入站事件,它的目的仅仅是完成Channel的初始化 - .childHandler(new ChannelInitializer() { - @Override - protected void initChannel(SocketChannel channel) { - //将我们自定义的ChannelHandler添加到流水线 - channel.pipeline().addLast(new TestChannelHandler()); - } - }); - bootstrap.bind(8080); -} -``` - -现在我们启动服务器,让客户端来连接并发送一下数据试试看: - -![image-20220510092703319](https://tva1.sinaimg.cn/large/e6c9d24egy1h231vpff1tj21j60900tp.jpg) - -可以看到ChannelInboundHandler的整个生命周期,首先是Channel注册成功,然后才会变成可用状态,接着就差不多可以等待客户端来数据了,当客户端主动断开连接时,会再次触发一次`channelReadComplete`,然后不可用,最后取消注册。 - -我们来测试一下出现异常的情况呢? - -```java -public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - ByteBuf buf = (ByteBuf) msg; - System.out.println(Thread.currentThread().getName()+" >> 接收到客户端发送的数据:"+buf.toString(StandardCharsets.UTF_8)); - ByteBuf back = ctx.alloc().buffer(); - back.writeCharSequence("已收到!", StandardCharsets.UTF_8); - ctx.writeAndFlush(back); - System.out.println("channelRead"); - throw new RuntimeException("我是自定义异常1"); //弄点异常上去 -} - -public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { - System.out.println("channelReadComplete"); - throw new RuntimeException("我是自定义异常2"); //弄点异常上去 -} - -... - -public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { - System.out.println("exceptionCaught"+cause); -} -``` - -可以看到发生异常时,会接着调用`exceptionCaught`方法: - -![image-20220510094007913](https://tva1.sinaimg.cn/large/e6c9d24egy1h2329bmho8j21g407y75e.jpg) - -与ChannelInboundHandler对应的还有ChannelOutboundHandler用于处理出站相关的操作,这里就不进行演示了。 - -我们接着来看看ChannelPipeline,每一个Channel都对应一个ChannelPipeline(在Channel初始化时就被创建了) - -![image-20220511152035030](https://tva1.sinaimg.cn/large/e6c9d24ely1h24hpuq1sej219i08wgms.jpg) - -它就像是一条流水线一样,整条流水线上可能会有很多个Handler(包括入站和出站),整条流水线上的两端还有两个默认的处理器(用于一些预置操作和后续操作,比如释放资源等),我们只需要关心如何安排这些自定义的Handler即可,比如我们现在希望创建两个入站ChannelHandler,一个用于接收请求并处理,还有一个用于处理当前接收请求过程中出现的异常: - -```java -.childHandler(new ChannelInitializer() { //注意,这里的SocketChannel不是我们NIO里面的,是Netty的 - @Override - protected void initChannel(SocketChannel channel) { - channel.pipeline() //直接获取pipeline,然后添加两个Handler,注意顺序 - .addLast(new ChannelInboundHandlerAdapter(){ //第一个用于处理消息接收 - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - ByteBuf buf = (ByteBuf) msg; - System.out.println("接收到客户端发送的数据:"+buf.toString(StandardCharsets.UTF_8)); - throw new RuntimeException("我是异常"); - } - }) - .addLast(new ChannelInboundHandlerAdapter(){ //第二个用于处理异常 - @Override - public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { - System.out.println("我是异常处理:"+cause); - } - }); - } -}); -``` - -那么它是如何运作的呢?实际上如果我们不在ChannelInboundHandlerAdapter中重写对应的方法,它会默认传播到流水线的下一个ChannelInboundHandlerAdapter进行处理,比如: - -```java -public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { - ctx.fireExceptionCaught(cause); //通过ChannelHandlerContext来向下传递,ChannelHandlerContext是在Handler添加进Pipeline中时就被自动创建的 -} -``` - -比如我们现在需要将一个消息在两个Handler中进行处理: - -```java -@Override -protected void initChannel(SocketChannel channel) { - channel.pipeline() //直接获取pipeline,然后添加两个Handler - .addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - ByteBuf buf = (ByteBuf) msg; - System.out.println("1接收到客户端发送的数据:"+buf.toString(StandardCharsets.UTF_8)); - ctx.fireChannelRead(msg); //通过ChannelHandlerContext - } - }) - .addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - ByteBuf buf = (ByteBuf) msg; - System.out.println("2接收到客户端发送的数据:"+buf.toString(StandardCharsets.UTF_8)); - } - }); -} -``` - -我们接着来看看出站相关操作,我们可以使用ChannelOutboundHandlerAdapter来完成: - -```java -@Override -protected void initChannel(SocketChannel channel) { - channel.pipeline() - .addLast(new ChannelOutboundHandlerAdapter(){ - //注意出栈站操作应该在入站操作的前面,当我们使用ChannelHandlerContext的write方法时,是从流水线的当前位置倒着往前找下一个ChannelOutboundHandlerAdapter,而我们之前使用的ChannelInboundHandlerAdapter是从前往后找下一个,如果我们使用的是Channel的write方法,那么会从整个流水线的最后开始倒着往前找ChannelOutboundHandlerAdapter,一定要注意顺序。 - @Override - public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { //当执行write操作时,会 - System.out.println(msg); //write的是啥,这里就是是啥 - //我们将其转换为ByteBuf,这样才能发送回客户端 - ctx.writeAndFlush(Unpooled.wrappedBuffer(msg.toString().getBytes())); - } - }) - .addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - ByteBuf buf = (ByteBuf) msg; - System.out.println("1接收到客户端发送的数据:"+buf.toString(StandardCharsets.UTF_8)); - ctx.fireChannelRead(msg); - } - }) - .addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - ByteBuf buf = (ByteBuf) msg; - System.out.println("2接收到客户端发送的数据:"+buf.toString(StandardCharsets.UTF_8)); - ctx.writeAndFlush("不会吧不会吧,不会还有人都看到这里了还没三连吧"); //这里可以write任何对象 - //ctx.channel().writeAndFlush("啊对对对"); 或是通过Channel进行write也可以 - } - }); -} -``` - -现在我们来试试看,搞两个出站的Handler,验证一下是不是上面的样子: - -```java -@Override -protected void initChannel(SocketChannel channel) { - channel.pipeline() //直接获取pipeline,然后添加两个Handler - .addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - ByteBuf buf = (ByteBuf) msg; - System.out.println("1接收到客户端发送的数据:"+buf.toString(StandardCharsets.UTF_8)); - ctx.fireChannelRead(msg); - } - }) - .addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - ByteBuf buf = (ByteBuf) msg; - System.out.println("2接收到客户端发送的数据:"+buf.toString(StandardCharsets.UTF_8)); - ctx.channel().writeAndFlush("伞兵一号卢本伟"); //这里我们使用channel的write - } - }) - .addLast(new ChannelOutboundHandlerAdapter(){ - @Override - public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { - System.out.println("1号出站:"+msg); - } - }) - .addLast(new ChannelOutboundHandlerAdapter(){ - @Override - public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { - System.out.println("2号出站:"+msg); - ctx.write(msg); //继续write给其他的出站Handler,不然到这里就断了 - } - }); -} -``` - -所以,出站操作在流水线上是反着来的,整个流水线操作大概流程如下: - -![image-20220510130021714](https://tva1.sinaimg.cn/large/e6c9d24egy1h2381nnw6kj21ey0bitam.jpg) - -有关Channel及其处理相关操作,就先讲到这里。 - -### EventLoop和任务调度 - -前面我们讲解了Channel,那么在EventLoop中具体是如何进行调度的呢?实际上我们之前在编写NIO的时候,就是一个while循环在源源不断地等待新的事件,而EventLoop也正是这种思想,它本质就是一个事件等待/处理线程。 - -![image-20220510133359757](https://tva1.sinaimg.cn/large/e6c9d24egy1h2390nmv60j21es0awta1.jpg) - -我们上面使用的就是EventLoopGroup,包含很多个EventLoop,我们每创建一个连接,就需要绑定到一个EventLoop上,之后EventLoop就会开始监听这个连接(只要连接不关闭,一直都是这个EventLoop负责此Channel),而一个EventLoop可以同时监听很多个Channel,实际上就是我们之前学习的Selector罢了。 - -当然,EventLoop并不只是用于网络操作的,我们前面所说的EventLoop其实都是NioEventLoop,它是专用于网络通信的,除了网络通信之外,我们也可以使用普通的EventLoop来处理一些其他的事件。 - -比如我们现在编写的服务端,虽然结构上和主从Reactor多线程模型差不多,但是我们发现,Handler似乎是和读写操作在一起进行的,而我们之前所说的模型中,Handler是在读写之外的单独线程中进行的: - -```java -public static void main(String[] args) { - EventLoopGroup bossGroup = new NioEventLoopGroup(), workerGroup = new NioEventLoopGroup(1); //线程数先限制一下 - ServerBootstrap bootstrap = new ServerBootstrap(); - bootstrap - .group(bossGroup, workerGroup) //指定事件循环组 - .channel(NioServerSocketChannel.class) //指定为NIO的ServerSocketChannel - .childHandler(new ChannelInitializer() { //注意,这里的SocketChannel不是我们NIO里面的,是Netty的 - @Override - protected void initChannel(SocketChannel channel) { - channel.pipeline() - .addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - ByteBuf buf = (ByteBuf) msg; - System.out.println("接收到客户端发送的数据:"+buf.toString(StandardCharsets.UTF_8)); - Thread.sleep(10000); //这里我们直接卡10秒假装在处理任务 - ctx.writeAndFlush(Unpooled.wrappedBuffer("已收到!".getBytes())); - } - }); - } - }); - bootstrap.bind(8080); -} -``` - -可以看到,如果在这里卡住了,那么就没办法处理EventLoop绑定的其他Channel了,所以我们这里就创建一个普通的EventLoop来专门处理读写之外的任务: - -```java -public static void main(String[] args) { - EventLoopGroup bossGroup = new NioEventLoopGroup(), workerGroup = new NioEventLoopGroup(1); //线程数先限制一下 - EventLoopGroup handlerGroup = new DefaultEventLoopGroup(); //使用DefaultEventLoop来处理其他任务 - ServerBootstrap bootstrap = new ServerBootstrap(); - bootstrap - .group(bossGroup, workerGroup) - .channel(NioServerSocketChannel.class) - .childHandler(new ChannelInitializer() { - @Override - protected void initChannel(SocketChannel channel) { - channel.pipeline() - .addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - ByteBuf buf = (ByteBuf) msg; - System.out.println("接收到客户端发送的数据:"+buf.toString(StandardCharsets.UTF_8)); - handlerGroup.submit(() -> { - //由于继承自ScheduledExecutorService,我们直接提交任务就行了,是不是感觉贼方便 - try { - Thread.sleep(10000); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - ctx.writeAndFlush(Unpooled.wrappedBuffer("已收到!".getBytes())); - }); - } - }); - } - }); - bootstrap.bind(8080); -} -``` - -当然我们也可以写成一条流水线: - -```java -public static void main(String[] args) { - EventLoopGroup bossGroup = new NioEventLoopGroup(), workerGroup = new NioEventLoopGroup(1); //线程数先限制一下 - EventLoopGroup handlerGroup = new DefaultEventLoopGroup(); //使用DefaultEventLoop来处理其他任务 - ServerBootstrap bootstrap = new ServerBootstrap(); - bootstrap - .group(bossGroup, workerGroup) - .channel(NioServerSocketChannel.class) - .childHandler(new ChannelInitializer() { - @Override - protected void initChannel(SocketChannel channel) { - channel.pipeline() - .addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - ByteBuf buf = (ByteBuf) msg; - System.out.println("接收到客户端发送的数据:"+buf.toString(StandardCharsets.UTF_8)); - ctx.fireChannelRead(msg); - } - }).addLast(handlerGroup, new ChannelInboundHandlerAdapter(){ //在添加时,可以直接指定使用哪个EventLoopGroup - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - try { - Thread.sleep(10000); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - ctx.writeAndFlush(Unpooled.wrappedBuffer("已收到!".getBytes())); - } - }); - } - }); - bootstrap.bind(8080); -} -``` - -这样,我们就进一步地将EventLoop利用起来了。 - -按照前面服务端的方式,我们来把Netty版本的客户端也给写了: - -```java -public static void main(String[] args) { - Bootstrap bootstrap = new Bootstrap(); //客户端也是使用Bootstrap来启动 - bootstrap - .group(new NioEventLoopGroup()) //客户端就没那么麻烦了,直接一个EventLoop就行,用于处理发回来的数据 - .channel(NioSocketChannel.class) //客户端肯定就是使用SocketChannel了 - .handler(new ChannelInitializer() { //这里的数据处理方式和服务端是一样的 - @Override - protected void initChannel(SocketChannel channel) throws Exception { - channel.pipeline().addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - ByteBuf buf = (ByteBuf) msg; - System.out.println(">> 接收到客户端发送的数据:"+buf.toString(StandardCharsets.UTF_8)); - } - }); - } - }); - Channel channel = bootstrap.connect("localhost", 8080).channel(); //连接后拿到对应的Channel对象 - //注意上面连接操作是异步的,调用之后会继续往下走,下面我们就正式编写客户端的数据发送代码了 - try(Scanner scanner = new Scanner(System.in)){ //还是和之前一样,扫了就发 - while (true) { - System.out.println("<< 请输入要发送给服务端的内容:"); - String text = scanner.nextLine(); - if(text.isEmpty()) continue; - channel.writeAndFlush(Unpooled.wrappedBuffer(text.getBytes())); //通过Channel对象发送数据 - } - } -} -``` - -我们来测试一下吧: - -![image-20220510144513303](https://tva1.sinaimg.cn/large/e6c9d24egy1h23b2raotjj21a607u3zd.jpg) - -### Future和Promise - -我们接着来看ChannelFuture,前面我们提到,Netty中Channel的相关操作都是异步进行的,并不是在当前线程同步执行,我们不能立即得到执行结果,如果需要得到结果,那么我们就必须要利用到Future。 - -我们先来看看ChannelFutuer接口怎么定义的: - -```java -public interface ChannelFuture extends Future { - Channel channel(); //我们可以直接获取此任务的Channel - ChannelFuture addListener(GenericFutureListener> var1); //当任务完成时,会直接执行GenericFutureListener的任务,注意执行的位置也是在EventLoop中 - ChannelFuture addListeners(GenericFutureListener>... var1); - ChannelFuture removeListener(GenericFutureListener> var1); - ChannelFuture removeListeners(GenericFutureListener>... var1); - ChannelFuture sync() throws InterruptedException; //在当前线程同步等待异步任务完成,任务失败会抛出异常 - ChannelFuture syncUninterruptibly(); //同上,但是无法响应中断 - ChannelFuture await() throws InterruptedException; //同上,但是任务中断不会抛出异常,需要手动判断 - ChannelFuture awaitUninterruptibly(); //不用我说了吧? - boolean isVoid(); //返回类型是否为void -} -``` - -此接口是继承自Netty中的Future接口的(不是JDK的那个): - -```java -public interface Future extends java.util.concurrent.Future { //再往上才是JDK的Future - boolean isSuccess(); //用于判断任务是否执行成功的 - boolean isCancellable(); - Throwable cause(); //获取导致任务失败的异常 - - ... - - V getNow(); //立即获取结果,如果还未产生结果,得到null,不过ChannelFuture定义V为Void,就算完成了获取也是null - boolean cancel(boolean var1); //取消任务 -} -``` - -Channel的很多操作都是异步完成的,直接返回一个ChannelFuture,比如Channel的write操作,返回的就是一个ChannelFuture对象: - -```java -.addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - ByteBuf buf = (ByteBuf) msg; - System.out.println("接收到客户端发送的数据:"+buf.toString(StandardCharsets.UTF_8)); - ChannelFuture future = ctx.writeAndFlush(Unpooled.wrappedBuffer("已收到!".getBytes())); - System.out.println("任务完成状态:"+future.isDone()); //通过ChannelFuture来获取相关信息 - } -}); -``` - -包括我们的服务端启动也是返回的ChannelFuture: - -```java -... - } - }); - ChannelFuture future = bootstrap.bind(8080); - System.out.println("服务端启动状态:"+future.isDone()); - System.out.println("我是服务端启动完成之后要做的事情!"); -} -``` - -可以看到,服务端的启动就比较慢了,所以在一开始直接获取状态会返回`false`,但是这个时候我们又需要等到服务端启动完成之后做一些事情,这个时候该怎么办呢?现在我们就有两种方案了: - -```java - } - }); - ChannelFuture future = bootstrap.bind(8080); - future.sync(); //让当前线程同步等待任务完成 - System.out.println("服务端启动状态:"+future.isDone()); - System.out.println("我是服务端启动完成之后要做的事情!"); -} -``` - -第一种方案是直接让当前线程同步等待异步任务完成,我们可以使用`sync()`方法,这样当前线程会一直阻塞直到任务结束。第二种方案是添加一个监听器,等待任务完成时通知: - -```java - } - }); - ChannelFuture future = bootstrap.bind(8080); - //直接添加监听器,当任务完成时自动执行,但是注意执行也是异步的,不是在当前线程 - future.addListener(f -> System.out.println("我是服务端启动完成之后要做的事情!")); -} -``` - -包括客户端的关闭,也是异步进行的: - -```java -try(Scanner scanner = new Scanner(System.in)){ - while (true) { - System.out.println("<< 请输入要发送给服务端的内容:"); - String text = scanner.nextLine(); - if(text.isEmpty()) continue; - if(text.equals("exit")) { //输入exit就退出 - ChannelFuture future = channel.close(); - future.sync(); //等待Channel完全关闭 - break; - } - channel.writeAndFlush(Unpooled.wrappedBuffer(text.getBytes())); - } -} catch (InterruptedException e) { - throw new RuntimeException(e); -} finally { - group.shutdownGracefully(); //优雅退出EventLoop,其实就是把还没发送的数据之类的事情做完,当然也可以shutdownNow立即关闭 -} -``` - -我们接着来看看Promise接口,它支持手动设定成功和失败的结果: - -```java -//此接口也是继承自Netty中的Future接口 -public interface Promise extends Future { - Promise setSuccess(V var1); //手动设定成功 - boolean trySuccess(V var1); - Promise setFailure(Throwable var1); //手动设定失败 - boolean tryFailure(Throwable var1); - boolean setUncancellable(); - //这些就和之前的Future是一样的了 - Promise addListener(GenericFutureListener> var1); - Promise addListeners(GenericFutureListener>... var1); - Promise removeListener(GenericFutureListener> var1); - Promise removeListeners(GenericFutureListener>... var1); - Promise await() throws InterruptedException; - Promise awaitUninterruptibly(); - Promise sync() throws InterruptedException; - Promise syncUninterruptibly(); -} -``` - -比如我们来测试一下: - -```java -public static void main(String[] args) throws ExecutionException, InterruptedException { - Promise promise = new DefaultPromise<>(new DefaultEventLoop()); - System.out.println(promise.isSuccess()); //在一开始肯定不是成功的 - promise.setSuccess("lbwnb"); //设定成功 - System.out.println(promise.isSuccess()); //再次获取,可以发现确实成功了 - System.out.println(promise.get()); //获取结果,就是我们刚刚给进去的 -} -``` - -可以看到我们可以手动指定成功状态,包括ChannelOutboundInvoker中的一些基本操作,都是支持ChannelPromise的: - -```java -.addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - ByteBuf buf = (ByteBuf) msg; - String text = buf.toString(StandardCharsets.UTF_8); - System.out.println("接收到客户端发送的数据:"+text); - ChannelPromise promise = new DefaultChannelPromise(channel); - System.out.println(promise.isSuccess()); - ctx.writeAndFlush(Unpooled.wrappedBuffer("已收到!".getBytes()), promise); - promise.sync(); //同步等待一下 - System.out.println(promise.isSuccess()); - } -}); -``` - -最后结果就是我们想要的了,当然我们也可以像Future那样添加监听器,当成功时自动通知: - -```java -public static void main(String[] args) throws ExecutionException, InterruptedException { - Promise promise = new DefaultPromise<>(new DefaultEventLoop()); - promise.addListener(f -> System.out.println(promise.get())); //注意是在上面的DefaultEventLoop执行的 - System.out.println(promise.isSuccess()); - promise.setSuccess("lbwnb"); - System.out.println(promise.isSuccess()); -} -``` - -有关Future和Promise就暂时讲解到这里。 - -### 编码器和解码器 - -前面我们已经了解了Netty的大部分基础内容,我们接着来看看Netty内置的一些编码器和解码器。 - -在前面的学习中,我们的数据发送和接收都是需要以ByteBuf形式传输,但是这样是不是有点太不方便了,咱们能不能参考一下JavaWeb那种搞个Filter,在我们开始处理数据之前,过过滤一次,并在过滤的途中将数据转换成我们想要的类型,也可以将发出的数据进行转换,这就要用到编码解码器了。 - -我们先来看看最简的,字符串,如果我们要直接在客户端或是服务端处理字符串,可以直接添加一个字符串解码器到我们的流水线中: - -```java -@Override -protected void initChannel(SocketChannel channel) { - channel.pipeline() - //解码器本质上也算是一种ChannelInboundHandlerAdapter,用于处理入站请求 - .addLast(new StringDecoder()) //当客户端发送来的数据只是简单的字符串转换的ByteBuf时,我们直接使用内置的StringDecoder即可转换 - .addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - //经过StringDecoder转换后,msg直接就是一个字符串,所以打印就行了 - System.out.println(msg); - } - }); -} -``` - -可以看到,使用起来还是非常方便的,我们只需要将其添加到流水线即可,实际上器本质就是一个ChannelInboundHandlerAdapter: - -![image-20220511123807650](https://tva1.sinaimg.cn/large/e6c9d24ely1h24d0v15dkj21zs0k6ad5.jpg) - -我们看到它是继承自MessageToMessageDecoder,用于将传入的Message转换为另一种类型,我们也可以自行编写一个实现: - -```java -/** - * 我们也来搞一个自定义的 - */ -public class TestDecoder extends MessageToMessageDecoder { - @Override - protected void decode(ChannelHandlerContext channelHandlerContext, ByteBuf buf, List list) throws Exception { - System.out.println("数据已收到,正在进行解码..."); - String text = buf.toString(StandardCharsets.UTF_8); //直接转换为UTF8字符串 - list.add(text); //解码后需要将解析后的数据丢进List中,如果丢进去多个数据,相当于数据被分成了多个,后面的Handler就需要每个都处理一次 - } -} -``` - -运行,可以看到: - -![image-20220511124755974](https://tva1.sinaimg.cn/large/e6c9d24ely1h24db102gqj214o02qq2x.jpg) - -当然如果我们在List里面丢很多个数据的话: - -```java -public class TestDecoder extends MessageToMessageDecoder { - @Override - protected void decode(ChannelHandlerContext channelHandlerContext, ByteBuf buf, List list) throws Exception { - System.out.println("数据已收到,正在进行解码..."); - String text = buf.toString(StandardCharsets.UTF_8); //直接转换为UTF8字符串 - list.add(text); - list.add(text+"2"); - list.add(text+'3'); //一条消息被解码成三条消息 - } -} -``` - -![image-20220511124933026](https://tva1.sinaimg.cn/large/e6c9d24ely1h24dcpcdxoj215e04eq2z.jpg) - -可以看到,后面的Handler会依次对三条数据都进行处理,当然,除了MessageToMessageDecoder之外,还有其他类型的解码器,比如ByteToMessageDecoder等,这里就不一一介绍了,Netty内置了很多的解码器实现来方便我们开发,比如HTTP(下一节介绍),SMTP、MQTT等,以及我们常用的Redis、Memcached、JSON等数据包。 - -当然,有了解码器处理发来的数据,那发出去的数据肯定也是需要被处理的,所以编码器就出现了: - -```java -channel.pipeline() - //解码器本质上也算是一种ChannelInboundHandlerAdapter,用于处理入站请求 - .addLast(new StringDecoder()) - .addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - System.out.println("收到客户端的数据:"+msg); - ctx.channel().writeAndFlush("可以,不跟你多BB"); //直接发字符串回去 - } - }) - .addLast(new StringEncoder()); //使用内置的StringEncoder可以直接将出站的字符串数据编码成ByteBuf -``` - -和上面的StringDecoder一样,StringEncoder本质上就是一个ChannelOutboundHandlerAdapter: - -![image-20220511130100984](https://tva1.sinaimg.cn/large/e6c9d24ely1h24domthrqj21qe0i8mzp.jpg) - -是不是感觉前面学习的Handler和Pipeline突然就变得有用了,直接一条线把数据处理安排得明明白白啊。 - -现在我们把客户端也改成使用编码、解码器的样子: - -```java -public static void main(String[] args) { - Bootstrap bootstrap = new Bootstrap(); - bootstrap - .group(new NioEventLoopGroup()) - .channel(NioSocketChannel.class) - .handler(new ChannelInitializer() { - @Override - protected void initChannel(SocketChannel channel) throws Exception { - channel.pipeline() - .addLast(new StringDecoder()) //解码器安排 - .addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - System.out.println(">> 接收到客户端发送的数据:" + msg); //直接接收字符串 - } - }) - .addLast(new StringEncoder()); //编码器安排 - } - }); - Channel channel = bootstrap.connect("localhost", 8080).channel(); - try(Scanner scanner = new Scanner(System.in)){ - while (true) { - System.out.println("<< 请输入要发送给服务端的内容:"); - String text = scanner.nextLine(); - if(text.isEmpty()) continue; - channel.writeAndFlush(text); //直接发送字符串就行 - } - } -} -``` - -这样我们的代码量又蹭蹭的减少了很多: - -![image-20220511130605337](https://tva1.sinaimg.cn/large/e6c9d24ely1h24dtwy88pj214y04ojrv.jpg) - -当然,除了编码器和解码器之外,还有编解码器。??缝合怪?? - -![image-20220511130937624](https://tva1.sinaimg.cn/large/e6c9d24ely1h24dxlf02nj21qu0fyn0h.jpg) - -可以看到它是既继承了ChannelInboundHandlerAdapter也实现了ChannelOutboundHandler接口,又能处理出站也能处理入站请求,实际上就是将之前的给组合到一起了,比如我们也可以实现一个缝合在一起的StringCodec类: - -```java -//需要指定两个泛型,第一个是入站的消息类型,还有一个是出站的消息类型,出站是String类型,我们要转成ByteBuf -public class StringCodec extends MessageToMessageCodec { - - @Override - protected void encode(ChannelHandlerContext channelHandlerContext, String buf, List list) throws Exception { - System.out.println("正在处理出站数据..."); - list.add(Unpooled.wrappedBuffer(buf.getBytes())); //同样的,添加的数量就是出站的消息数量 - } - - @Override - protected void decode(ChannelHandlerContext channelHandlerContext, ByteBuf buf, List list) throws Exception { - System.out.println("正在处理入站数据..."); - list.add(buf.toString(StandardCharsets.UTF_8)); //和之前一样,直接一行解决 - } -} -``` - -可以看到实际上就是需要我们同时去实现编码和解码方法,继承MessageToMessageCodec类即可。 - -当然,如果整条流水线上有很多个解码器或是编码器,那么也可以多次进行编码或是解码,比如: - -```java -public class StringToStringEncoder extends MessageToMessageEncoder { - - @Override - protected void encode(ChannelHandlerContext channelHandlerContext, String s, List list) throws Exception { - System.out.println("我是预处理编码器,就要皮这一下。"); - list.add("[已处理] "+s); - } -} -``` - -```java -channel.pipeline() - //解码器本质上也算是一种ChannelInboundHandlerAdapter,用于处理入站请求 - .addLast(new StringDecoder()) - .addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - System.out.println("收到客户端的数据:"+msg); - ctx.channel().writeAndFlush("可以,不跟你多BB"); //直接发字符串回去 - } - }) - .addLast(new StringEncoder()) //最后再转成ByteBuf - .addLast(new StringToStringEncoder()); //先从我们自定义的开始 -``` - -可以看到,数据在流水线上一层一层处理最后再回到的客户端: - -![image-20220511133025492](https://tva1.sinaimg.cn/large/e6c9d24ely1h24ej8u6cqj219m04cq3f.jpg) - -我们在一开始提到的粘包/拆包问题,也可以使用一个解码器解决: - -```java -channel.pipeline() - .addLast(new FixedLengthFrameDecoder(10)) - //第一种解决方案,使用定长数据包,每个数据包都要是指定长度 - ... -``` - -```java -channel.pipeline() - .addLast(new DelimiterBasedFrameDecoder(1024, Unpooled.wrappedBuffer("!".getBytes()))) - //第二种,就是指定一个特定的分隔符,比如我们这里以感叹号为分隔符 - //在收到分隔符之前的所有数据,都作为同一个数据包的内容 -``` - -```java -channel.pipeline() - .addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4)) - //第三种方案,就是在头部添加长度信息,来确定当前发送的数据包具体长度是多少 - //offset是从哪里开始,length是长度信息占多少字节,这里是从0开始读4个字节表示数据包长度 - .addLast(new StringDecoder()) -``` - -```java -channel.pipeline() - .addLast(new StringDecoder()) - .addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - System.out.println(">> 接收到客户端发送的数据:" + msg); - } - }) - .addLast(new LengthFieldPrepender(4)) //客户端在发送时也需要将长度拼到前面去 - .addLast(new StringEncoder()); -``` - -有关编码器和解码器的内容就先介绍到这里。 - -### 实现HTTP协议通信 - -前面我们介绍了Netty为我们提供的编码器和解码器,这里我们就来使用一下支持HTTP协议的编码器和解码器。 - -```java -channel.pipeline() - .addLast(new HttpRequestDecoder()) //Http请求解码器 - .addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - System.out.println("收到客户端的数据:"+msg.getClass()); //看看是个啥类型 - //收到浏览器请求后,我们需要给一个响应回去 - FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); //HTTP版本为1.1,状态码就OK(200)即可 - //直接向响应内容中写入数据 - response.content().writeCharSequence("Hello World!", StandardCharsets.UTF_8); - ctx.channel().writeAndFlush(response); //发送响应 - ctx.channel().close(); //HTTP请求是一次性的,所以记得关闭 - } - }) - .addLast(new HttpResponseEncoder()); //响应记得也要编码后发送哦 -``` - -现在我们用浏览器访问一下我们的服务器吧: - -![image-20220511142040941](https://tva1.sinaimg.cn/large/e6c9d24ely1h24fzjztiyj22ac0k2gox.jpg) - -可以看到浏览器成功接收到服务器响应,然后控制台打印了以下类型: - -![image-20220511142121619](https://tva1.sinaimg.cn/large/e6c9d24ely1h24g08odmrj21e604kmyh.jpg) - -可以看到一次请求是一个DefaultHttpRequest+LastHttpContent$1,这里有两组是因为浏览器请求了一个地址之后紧接着请求了我们网站的favicon图标。 - -这样把数据分开处理肯定是不行的,要是直接整合成一个多好,安排: - -```java -channel.pipeline() - .addLast(new HttpRequestDecoder()) //Http请求解码器 - .addLast(new HttpObjectAggregator(Integer.MAX_VALUE)) //搞一个聚合器,将内容聚合为一个FullHttpRequest,参数是最大内容长度 - .addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - FullHttpRequest request = (FullHttpRequest) msg; - System.out.println("浏览器请求路径:"+request.uri()); //直接获取请求相关信息 - FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); - response.content().writeCharSequence("Hello World!", StandardCharsets.UTF_8); - ctx.channel().writeAndFlush(response); - ctx.channel().close(); - } - }) - .addLast(new HttpResponseEncoder()); -``` - -再次访问,我们发现可以正常读取请求路径了: - -![image-20220511143318500](https://tva1.sinaimg.cn/large/e6c9d24ely1h24gcntjzbj210c02e74b.jpg) - -我们来试试看搞个静态页面代理玩玩,拿出我们的陈年老模板: - -![image-20220511144020424](https://tva1.sinaimg.cn/large/e6c9d24ely1h24gjzm12aj214i0k842b.jpg) - -全部放进Resource文件夹,一会根据浏览器的请求路径,我们就可以返回对应的页面了,先安排一个解析器,用于解析路径然后将静态页面的内容返回: - -```java -public class PageResolver { - //直接单例模式 - private static final PageResolver INSTANCE = new PageResolver(); - private PageResolver(){} - public static PageResolver getInstance(){ - return INSTANCE; - } - - //请求路径给进来,接着我们需要将页面拿到,然后转换成响应数据包发回去 - public FullHttpResponse resolveResource(String path){ - if(path.startsWith("/")) { //判断一下是不是正常的路径请求 - path = path.equals("/") ? "index.html" : path.substring(1); //如果是直接请求根路径,那就默认返回index页面,否则就该返回什么路径的文件就返回什么 - try(InputStream stream = this.getClass().getClassLoader().getResourceAsStream(path)) { - if(stream != null) { //拿到文件输入流之后,才可以返回页面 - byte[] bytes = new byte[stream.available()]; - stream.read(bytes); - return this.packet(HttpResponseStatus.OK, bytes); //数据先读出来,然后交给下面的方法打包 - } - } catch (IOException e){ - e.printStackTrace(); - } - } - //其他情况一律返回404 - return this.packet(HttpResponseStatus.NOT_FOUND, "404 Not Found!".getBytes()); - } - - //包装成FullHttpResponse,把状态码和数据写进去 - private FullHttpResponse packet(HttpResponseStatus status, byte[] data){ - FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status); - response.content().writeBytes(data); - return response; - } -} -``` - -现在我们的静态资源解析就写好了,接着: - -```java -channel.pipeline() - .addLast(new HttpRequestDecoder()) //Http请求解码器 - .addLast(new HttpObjectAggregator(Integer.MAX_VALUE)) //搞一个聚合器,将内容聚合为一个FullHttpRequest,参数是最大内容长度 - .addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - FullHttpRequest request = (FullHttpRequest) msg; - //请求进来了直接走解析 - PageResolver resolver = PageResolver.getInstance(); - ctx.channel().writeAndFlush(resolver.resolveResource(request.uri())); - ctx.channel().close(); - } - }) - .addLast(new HttpResponseEncoder()); -``` - -现在我们启动服务器来试试看吧: - -![image-20220511150714100](https://tva1.sinaimg.cn/large/e6c9d24ely1h24hbzfrozj21dy0u0jw2.jpg) - -可以看到页面可以正常展示了,是不是有Tomcat哪味了。 - -### 其他内置Handler介绍 - -Netty也为我们内置了一些其他比较好用的Handler,比如我们要打印日志: - -```java -channel.pipeline() - .addLast(new HttpRequestDecoder()) - .addLast(new HttpObjectAggregator(Integer.MAX_VALUE)) - .addLast(new LoggingHandler(LogLevel.INFO)) //添加一个日志Handler,在请求到来时会自动打印相关日志 - ... -``` - -日志级别我们选择INFO,现在我们用浏览器访问一下: - -![image-20220512125851248](https://tva1.sinaimg.cn/large/e6c9d24egy1h25j8p1xc2j22ig0sutk2.jpg) - -可以看到每次请求的内容和详细信息都会在日志中出现,包括详细的数据包解析过程,请求头信息都是完整地打印在控制台上的。 - -我们也可以使用Handler对IP地址进行过滤,比如我们不希望某些IP地址连接我们的服务器: - -```java -channel.pipeline() - .addLast(new HttpRequestDecoder()) - .addLast(new HttpObjectAggregator(Integer.MAX_VALUE)) - .addLast(new RuleBasedIpFilter(new IpFilterRule() { - @Override - public boolean matches(InetSocketAddress inetSocketAddress) { - return !inetSocketAddress.getHostName().equals("127.0.0.1"); - //进行匹配,返回false表示匹配失败 - //如果匹配失败,那么会根据下面的类型决定该干什么,比如我们这里判断是不是本地访问的,如果是那就拒绝 - } - - @Override - public IpFilterRuleType ruleType() { - return IpFilterRuleType.REJECT; //类型,REJECT表示拒绝连接,ACCEPT表示允许连接 - } - })) -``` - -现在我们浏览器访问一下看看: - -![image-20220512130926968](https://tva1.sinaimg.cn/large/e6c9d24egy1h25jjq53pvj21r40m8abh.jpg) - -我们也可以对那些长期处于空闲的进行处理: - -```java -channel.pipeline() - .addLast(new StringDecoder()) - .addLast(new IdleStateHandler(10, 10, 0)) //IdleStateHandler能够侦测连接空闲状态 - //第一个参数表示连接多少秒没有读操作时触发事件,第二个是写操作,第三个是读写操作都算,0表示禁用 - //事件需要在ChannelInboundHandlerAdapter中进行监听处理 - .addLast(new ChannelInboundHandlerAdapter(){ - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - System.out.println("收到客户端数据:"+msg); - ctx.channel().writeAndFlush("已收到!"); - } - - @Override - public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { - //没想到吧,这个方法原来是在这个时候用的 - if(evt instanceof IdleStateEvent) { - IdleStateEvent event = (IdleStateEvent) evt; - if(event.state() == IdleState.WRITER_IDLE) { - System.out.println("好久都没写了,看视频的你真的有认真在跟着敲吗"); - } else if(event.state() == IdleState.READER_IDLE) { - System.out.println("已经很久很久没有读事件发生了,好寂寞"); - } - } - } - }) - .addLast(new StringEncoder()); -``` - -可以看到,当我们超过一段时间不发送数据时,就会这样: - -![image-20220512131845296](https://tva1.sinaimg.cn/large/e6c9d24egy1h25jteal4rj21eo07qabh.jpg) - -通过这种机制,我们就可以直接关掉那些占着茅坑不拉屎的连接。 - -### 启动流程源码解读 - -前面我们完成了对Netty基本功能的讲解,我们最后就来看一下,Netty到底是如何启动以及进行数据处理的。 - -首先我们知道,整个服务端是在bind之后启动的,那么我们就从这里开始下手,不多BB直接上源码: - -```java -public ChannelFuture bind(int inetPort) { - return this.bind(new InetSocketAddress(inetPort)); //转换成InetSocketAddress对象 -} -``` - -进来之后发现是调用的其他绑定方法,继续: - -```java -public ChannelFuture bind(SocketAddress localAddress) { - this.validate(); //再次验证一下,看看EventLoopGroup和Channel指定了没 - return this.doBind((SocketAddress)ObjectUtil.checkNotNull(localAddress, "localAddress")); -} -``` - -我们继续往下看: - -```java -private ChannelFuture doBind(final SocketAddress localAddress) { - final ChannelFuture regFuture = this.initAndRegister(); //上来第一句初始化然后注册 - ... -} -``` - -我们看看是怎么注册的: - -```java -final ChannelFuture initAndRegister() { - Channel channel = null; - - try { - channel = this.channelFactory.newChannel(); //通过channelFactory创建新的Channel,实际上就是我们在一开始设定的NioServerSocketChannel - this.init(channel); //接着对创建好的NioServerSocketChannel进行初始化 - ... - - ChannelFuture regFuture = this.config().group().register(channel); //将通道注册到bossGroup中的一个EventLoop中 - ... - return regFuture; -} -``` - -我们来看看是如何对创建好的ServerSocketChannel进行初始化的: - -```java -void init(Channel channel) { - setChannelOptions(channel, this.newOptionsArray(), logger); - setAttributes(channel, this.newAttributesArray()); - ChannelPipeline p = channel.pipeline(); - ... - //在流水线上添加一个Handler,在Handler初始化的时候向EventLoop中提交一个任务,将ServerBootstrapAcceptor添加到流水线上 - //这样我们的ServerSocketChannel在客户端连接时就能Accept了 - p.addLast(new ChannelHandler[]{new ChannelInitializer() { - public void initChannel(final Channel ch) { - final ChannelPipeline pipeline = ch.pipeline(); - ChannelHandler handler = ServerBootstrap.this.config.handler(); - if (handler != null) { - pipeline.addLast(new ChannelHandler[]{handler}); - } - - ch.eventLoop().execute(new Runnable() { - public void run() { - //这里提交一个任务,将ServerBootstrapAcceptor添加到ServerSocketChannel的pipeline中 - pipeline.addLast(new ChannelHandler[]{new ServerBootstrapAcceptor(ch, currentChildGroup, currentChildHandler, currentChildOptions, currentChildAttrs)}); - } - }); - } - }}); -} -``` - -我们来看一下,ServerBootstrapAcceptor怎么处理的,直接看到它的`channelRead`方法: - -```java -//当底层NIO的ServerSocketChannel的Selector有OP_ACCEPT事件到达时,NioEventLoop会接收客户端连接,创建SocketChannel,并触发channelRead回调 -public void channelRead(ChannelHandlerContext ctx, Object msg) { - //此时msg就是Accept连接创建之后的Channel对象 - final Channel child = (Channel)msg; - //这里直接将我们之前编写的childHandler添加到新创建的客户端连接的流水线中(是不是感觉突然就通了) - child.pipeline().addLast(new ChannelHandler[]{this.childHandler}); - AbstractBootstrap.setChannelOptions(child, this.childOptions, ServerBootstrap.logger); - AbstractBootstrap.setAttributes(child, this.childAttrs); - - try { - //直接向workGroup中的一个EventLoop注册新创建好的客户端连接Channel,等待读写事件 - this.childGroup.register(child).addListener(new ChannelFutureListener() { - //异步操作完成后,如果没有注册成功,就强制关闭这个Channel - public void operationComplete(ChannelFuture future) throws Exception { - if (!future.isSuccess()) { - ServerBootstrap.ServerBootstrapAcceptor.forceClose(child, future.cause()); - ... -``` - -所以,实际上就是我们之前讲解的主从Reactor多线程模型,只要前面理解了,这里其实很好推断。 - -初始化完成之后,我们来看看注册,在之前NIO阶段我们也是需要将Channel注册到对应的Selector才可以开始选择: - -```java -public ChannelFuture register(Channel channel) { - return this.register((ChannelPromise)(new DefaultChannelPromise(channel, this))); //转换成ChannelPromise继续 -} - -public ChannelFuture register(ChannelPromise promise) { - ObjectUtil.checkNotNull(promise, "promise"); - promise.channel().unsafe().register(this, promise); //调用Channel的Unsafe接口实现进行注册 - return promise; -} -``` - -继续向下: - -```java -public final void register(EventLoop eventLoop, final ChannelPromise promise) { - ... - AbstractChannel.this.eventLoop = eventLoop; - if (eventLoop.inEventLoop()) { - this.register0(promise); //这里是继续调用register0方法在进行注册 - } - ... - } -} -``` - -继续: - -```java -private void register0(ChannelPromise promise) { - try { - ... - - boolean firstRegistration = this.neverRegistered; - AbstractChannel.this.doRegister(); //这里开始执行AbstractNioChannel中的doRegister方法进行注册 - AbstractChannel.this.registered = true; - AbstractChannel.this.pipeline.invokeHandlerAddedIfNeeded(); - this.safeSetSuccess(promise); - if (AbstractChannel.this.isActive()) { - if (firstRegistration) { - AbstractChannel.this.pipeline.fireChannelActive(); //这里是关键 - } else if (AbstractChannel.this.config().isAutoRead()) { - this.beginRead(); - } - } - ... -} -``` - -来到最后一级: - -```java -protected void doRegister() throws Exception { - boolean selected = false; - - while(true) { - try { - //可以看到在这里终于是真正的进行了注册,javaChannel()得到NIO的Channel对象,然后调用register方法 - //这里就和我们之前NIO一样了,将Channel注册到Selector中,可以看到Selector也是EventLoop中的 - //但是注意,这里的ops参数是0,也就是不监听任何事件 - this.selectionKey = this.javaChannel().register(this.eventLoop().unwrappedSelector(), 0, this); - return; - ... - } -} -``` - -我们回到上一级,在doRegister完成之后,会拿到selectionKey,但是注意这时还没有监听任何事件,我们接着看到下面的fireChannelActive方法: - -```java -public final ChannelPipeline fireChannelActive() { - AbstractChannelHandlerContext.invokeChannelActive(this.head); //传的是流水线上的默认头结点 - return this; -} -``` - -```java -static void invokeChannelActive(final AbstractChannelHandlerContext next) { - EventExecutor executor = next.executor(); - if (executor.inEventLoop()) { - next.invokeChannelActive(); //继续向下 - } else { - executor.execute(new Runnable() { - public void run() { - next.invokeChannelActive(); - } - }); - } -} -``` - -```java -private void invokeChannelActive() { - if (this.invokeHandler()) { - try { - ((ChannelInboundHandler)this.handler()).channelActive(this); //依然是调用的头结点的channelActive方法进行处理 - } catch (Throwable var2) { - this.invokeExceptionCaught(var2); - } - } else { - this.fireChannelActive(); - } -} -``` - -```java -public void channelActive(ChannelHandlerContext ctx) { //这里是头结点的 - ctx.fireChannelActive(); - this.readIfIsAutoRead(); //继续向下 -} -``` - -```java -private void readIfIsAutoRead() { - if (DefaultChannelPipeline.this.channel.config().isAutoRead()) { - DefaultChannelPipeline.this.channel.read(); //继续不断向下 - } -} -``` - -```java -public void read(ChannelHandlerContext ctx) { - this.unsafe.beginRead(); //最后这里会调用beginRead方法 -} -``` - -```java -public final void beginRead() { - this.assertEventLoop(); - - try { - AbstractChannel.this.doBeginRead(); //这里就是调用AbstractNioChannel的doBeginRead方法了 - } catch (final Exception var2) { - this.invokeLater(new Runnable() { - public void run() { - AbstractChannel.this.pipeline.fireExceptionCaught(var2); - } - }); - this.close(this.voidPromise()); - } - -} -``` - -```java -protected void doBeginRead() throws Exception { - SelectionKey selectionKey = this.selectionKey; //先拿到之前注册好的selectionKey - if (selectionKey.isValid()) { - this.readPending = true; - int interestOps = selectionKey.interestOps(); //把监听的操作取出来 - if ((interestOps & this.readInterestOp) == 0) { //如果没有监听任何操作 - selectionKey.interestOps(interestOps | this.readInterestOp); //那就把readInterestOp事件进行监听,这里的readInterestOp实际上就是OP_ACCEPT - } - } -} -``` - -这样,Channel在初始化完成之后也完成了底层的注册,已经可以开始等待事件了。 - -我们现在回到之前的`doBind`方法的注册位置,现在注册完成之后,基本上整个主从Reactor结构就已经出来了,我们来看看还要做些什么: - -```java -private ChannelFuture doBind(final SocketAddress localAddress) { - final ChannelFuture regFuture = this.initAndRegister(); //目前初始化和注册都已经成功了 - final Channel channel = regFuture.channel(); //由于是异步操作,我们通过ChannelFuture拿到对应的ServerSocketChannel对象 - if (regFuture.cause() != null) { - return regFuture; - } else if (regFuture.isDone()) { //如果说初始化已经完成了 - ChannelPromise promise = channel.newPromise(); - doBind0(regFuture, channel, localAddress, promise); //直接开始进行进一步的绑定 - return promise; - } else { - //如果还没搞完,那就创Promis继续等待任务完成 - final PendingRegistrationPromise promise = new PendingRegistrationPromise(channel); - regFuture.addListener(new ChannelFutureListener() { - public void operationComplete(ChannelFuture future) throws Exception { - Throwable cause = future.cause(); - if (cause != null) { - promise.setFailure(cause); - } else { - promise.registered(); - AbstractBootstrap.doBind0(regFuture, channel, localAddress, promise); - } - - } - }); - return promise; - } -} -``` - -可以看到最后都会走到`doBind0`方法: - -```java -private static void doBind0(final ChannelFuture regFuture, final Channel channel, final SocketAddress localAddress, final ChannelPromise promise) { - //最后会向Channel已经注册到的EventLoop中提交一个新的任务 - channel.eventLoop().execute(new Runnable() { - public void run() { - if (regFuture.isSuccess()) { - //这里才是真正调用Channel底层进行绑定操作 - channel.bind(localAddress, promise).addListener(ChannelFutureListener.CLOSE_ON_FAILURE); - } else { - promise.setFailure(regFuture.cause()); - } - } - }); -} -``` - -至此,服务端的启动流程结束。我们前面还提到了NIO的空轮询问题,这里我们来看看Netty是如何解决的,我们直接定位到NioEventLoop中: - -```java -//由于代码太多,这里省略大部分代码 -while(true) { - boolean var34; - try { - ... - try { - if (!this.hasTasks()) { - strategy = this.select(curDeadlineNanos); //首先会在这里进行Selector.select()操作,跟NIO是一样的 - } - ... - - ++selectCnt; //每次唤醒都会让selectCnt自增 - this.cancelledKeys = 0; - - ... - - if (!ranTasks && strategy <= 0) { - if (this.unexpectedSelectorWakeup(selectCnt)) { //这里会进行判断是否出现空轮询BUG - ... -``` - -我们来看看是怎么进行判断的: - -```java -private boolean unexpectedSelectorWakeup(int selectCnt) { - if (Thread.interrupted()) { - if (logger.isDebugEnabled()) { - logger.debug("Selector.select() returned prematurely because Thread.currentThread().interrupt() was called. Use NioEventLoop.shutdownGracefully() to shutdown the NioEventLoop."); - } - - return true; - //如果selectCnt大于等于SELECTOR_AUTO_REBUILD_THRESHOLD(默认为512)那么会直接重建Selector - } else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 && selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) { - logger.warn("Selector.select() returned prematurely {} times in a row; rebuilding Selector {}.", selectCnt, this.selector); - this.rebuildSelector(); //当前的Selector出现BUG了,得重建一个Selector - return true; - } else { - return false; - } -} -``` - -实际上,当每次空轮询发生时会有专门的计数器+1,如果空轮询的次数超过了512次,就认为其触发了空轮询bug,触发bug后,Netty直接重建一个Selector,将原来的Channel重新注册到新的 Selector上,将旧的 Selector关掉,这样就防止了无限循环。 diff --git a/青空笔记/SpringBoot笔记/SpringBoot笔记(一).md b/青空笔记/SpringBoot笔记/SpringBoot笔记(一).md deleted file mode 100644 index 9ded267..0000000 --- a/青空笔记/SpringBoot笔记/SpringBoot笔记(一).md +++ /dev/null @@ -1,1745 +0,0 @@ -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg-blog.csdnimg.cn%2Fimg_convert%2F174788b2ec1d828d85a0a7ac65bea2cd.png&refer=http%3A%2F%2Fimg-blog.csdnimg.cn&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1644064714&t=46bead84c9b2fb6e0b65fd4afdaf805e) - -# SpringBoot一站式开发 - -官网:https://spring.io/projects/spring-boot - -> Spring Boot可以轻松创建独立的、基于Spring的生产级应用程序,它可以让你“运行即可”。大多数Spring Boot应用程序只需要少量的Spring配置。 - -SpringBoot功能: - -- 创建独立的Spring应用程序 -- 直接嵌入Tomcat、Jetty或Undertow(无需部署WAR包,打包成Jar本身就是一个可以运行的应用程序) -- 提供一站式的“starter”依赖项,以简化Maven配置(需要整合什么框架,直接导对应框架的starter依赖) -- 尽可能自动配置Spring和第三方库(除非特殊情况,否则几乎不需要你进行什么配置) -- 提供生产就绪功能,如指标、运行状况检查和外部化配置 -- 没有代码生成,也没有XML配置的要求(XML是什么,好吃吗) - -SpringBoot是现在最主流的开发框架,它提供了一站式的开发体验,大幅度提高了我们的开发效率。 - -## 走进SpringBoot - -在SSM阶段,当我们需要搭建一个基于Spring全家桶的Web应用程序时,我们不得不做大量的依赖导入和框架整合相关的Bean定义,光是整合框架就花费了我们大量的时间,但是实际上我们发现,整合框架其实基本都是一些固定流程,我们每创建一个新的Web应用程序,基本都会使用同样的方式去整合框架,我们完全可以将一些重复的配置作为约定,只要框架遵守这个约定,为我们提供默认的配置就好,这样就不用我们再去配置了,约定优于配置! - -而SpringBoot正是将这些过程大幅度进行了简化,它可以自动进行配置,我们只需要导入对应的启动器(starter)依赖即可。 - -完成本阶段的学习,基本能够胜任部分网站系统的后端开发工作,也建议同学们学习完SpringBoot之后寻找合适的队友去参加计算机项目相关的高校竞赛。 - -我们可以通过IDEA来演示如何快速创建一个SpringBoot项目,并且无需任何配置,就可以实现Bean注册。 - -## SpringBoot项目文件结构 - -我们在创建SpringBoot项目之后,首先会自动生成一个主类,而主类中的`main`方法中调用了`SpringApplication`类的静态方法来启动整个SpringBoot项目,并且我们可以看到主类的上方有一个`@SpringBootApplication`注解: - -```java -@SpringBootApplication -public class SpringBootTestApplication { - - public static void main(String[] args) { - SpringApplication.run(SpringBootTestApplication.class, args); - } - -} -``` - -同时还自带了一个测试类,测试类的上方仅添加了一个`@SpringBootTest`注解: - -```java -@SpringBootTest -class SpringBootTestApplicationTests { - - @Test - void contextLoads() { - - } - -} -``` - -我们接着来看Maven中写了哪些内容: - -```xml - - - 4.0.0 - - - org.springframework.boot - spring-boot-starter-parent - 2.6.2 - - - com.example - springboot-study - 0.0.1-SNAPSHOT - SpringBootTest - SpringBootTest - - 1.8 - - - - - org.springframework.boot - spring-boot-starter - - - - org.springframework.boot - spring-boot-starter-test - test - - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - - - -``` - -除了以上这些文件以外,我们的项目目录下还有: - -* .gitignore - Git忽略名单,下一章我们会专门讲解Git版本控制。 -* application.properties - SpringBoot的配置文件,所有依赖的配置都在这里编写,但是一般情况下只需要配置必要项即可。 - -*** - -## 整合Web相关框架 - -我们来看一下,既然我们前面提到SpringBoot会内嵌一个Tomcat服务器,也就是说我们的Jar打包后,相当于就是一个可以直接运行的应用程序,我们来看一下如何创建一个SpringBootWeb项目。 - -这里我们演示使用IDEA来创建一个基于SpringBoot的Web应用程序。 - -### 它是真的快 - -创建完成后,直接开启项目,我们就可以直接访问:http://localhost:8080/,我们可以看到,但是由于我们没有编写任何的请求映射,所以没有数据。我们可以来看看日志: - -``` -2022-01-06 22:17:46.308 INFO 853 --- [ main] c.example.SpringBootWebTestApplication : Starting SpringBootWebTestApplication using Java 1.8.0_312 on NagodeMacBook-Pro.local with PID 853 (/Users/nagocoler/Downloads/SpringBootWebTest/target/classes started by nagocoler in /Users/nagocoler/Downloads/SpringBootWebTest) -2022-01-06 22:17:46.309 INFO 853 --- [ main] c.example.SpringBootWebTestApplication : No active profile set, falling back to default profiles: default -2022-01-06 22:17:46.629 INFO 853 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http) -2022-01-06 22:17:46.632 INFO 853 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat] -2022-01-06 22:17:46.632 INFO 853 --- [ main] org.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/9.0.56] -2022-01-06 22:17:46.654 INFO 853 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext -2022-01-06 22:17:46.654 INFO 853 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 325 ms -2022-01-06 22:17:46.780 INFO 853 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path '' -2022-01-06 22:17:46.785 INFO 853 --- [ main] c.example.SpringBootWebTestApplication : Started SpringBootWebTestApplication in 0.62 seconds (JVM running for 0.999) -2022-01-06 22:18:02.979 INFO 853 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet' -2022-01-06 22:18:02.979 INFO 853 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet' -2022-01-06 22:18:02.980 INFO 853 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms -``` - -我们可以看到,日志中除了最基本的SpringBoot启动日志以外,还新增了内嵌Web服务器(Tomcat)的启动日志,并且显示了当前Web服务器所开放的端口,并且自动帮助我们初始化了DispatcherServlet,但是我们只是创建了项目,导入了web相关的starter依赖,没有进行任何的配置,实际上它使用的是starter提供的默认配置进行初始化的。 - -由于SpringBoot是自动扫描的,因此我们直接创建一个Controller即可被加载: - -```java -@Controller -public class MainController { - - //直接访问http://localhost:8080/index即可,不用加web应用程序名称了 - @RequestMapping("/index") - @ResponseBody - public String index(){ - return "你好,欢迎访问主页!"; - } -} -``` - -我们几乎没有做任何配置,但是可以直接开始配置Controller,SpringBoot创建一个Web项目的速度就是这么快! - -它还可以自动识别类型,如果我们返回的是一个对象类型的数据,那么它会自动转换为JSON数据格式,无需配置: - -```java -@Data -public class Student { - int sid; - String name; - String sex; -} -``` - -```java -@RequestMapping("/student") -@ResponseBody -public Student student(){ - Student student = new Student(); - student.setName("小明"); - student.setSex("男"); - student.setSid(10); - return student; -} -``` - -最后浏览器能够直接得到`application/json`的响应数据,就是这么方便。 - -### 修改Web相关配置 - -如果我们需要修改Web服务器的端口或是一些其他的内容,我们可以直接在`application.properties`中进行修改,它是整个SpringBoot的配置文件: - -```properties -# 修改端口为80 -server.port=80 -``` - -我们还可以编写自定义的配置项,并在我们的项目中通过`@Value`直接注入: - -```properties -test.data=100 -``` - -```java -@Controller -public class MainController { - - @Value("${test.data}") - int data; -``` - -通过这种方式,我们就可以更好地将一些需要频繁修改的配置项写在配置文件中,并通过注解方式去获取值。 - -配置文件除了使用`properties`格式以外,还有一种叫做`yaml`格式,它的语法如下: - -```yaml -一级目录: - 二级目录: - 三级目录1: 值 - 三级目录2: 值 - 三级目录List: - - 元素1 - - 元素2 - - 元素3 -``` - -我们可以看到,每一级目录都是通过缩进(不能使用Tab,只能使用空格)区分,并且键和值之间需要添加冒号+空格来表示。 - -SpringBoot也支持这种格式的配置文件,我们可以将`application.properties`修改为`application.yml`或是`application.yaml`来使用YAML语法编写配置: - -```yaml -server: - port: 80 -``` - -### 整合SpringSecurity依赖 - -我们接着来整合一下SpringSecurity依赖,继续感受SpringBoot带来的光速开发体验,只需要导入SpringSecurity的Starter依赖即可: - -```xml - - org.springframework.boot - spring-boot-starter-security - -``` - -导入依赖后,我们直接启动SpringBoot应用程序,可以发现SpringSecurity已经生效了。 - -并且SpringSecurity会自动为我们生成一个默认用户`user`,它的密码会出现在日志中: - -``` -2022-01-06 23:10:51.329 INFO 2901 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat] -2022-01-06 23:10:51.329 INFO 2901 --- [ main] org.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/9.0.56] -2022-01-06 23:10:51.350 INFO 2901 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext -2022-01-06 23:10:51.351 INFO 2901 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 341 ms -2022-01-06 23:10:51.469 INFO 2901 --- [ main] .s.s.UserDetailsServiceAutoConfiguration : - -Using generated security password: ff24bee3-e1b7-4309-9609-d32618baf5cb - -``` - -其中`ff24bee3-e1b7-4309-9609-d32618baf5cb`就是随机生成的一个密码,我们可以使用此用户登录。 - -我们也可以在配置文件中直接配置: - -```yaml -spring: - security: - user: - name: test # 用户名 - password: 123456 # 密码 - roles: # 角色 - - user - - admin -``` - -实际上这样的配置方式就是一个`inMemoryAuthentication`,只是我们可以直接配置而已。 - -当然,页面的控制和数据库验证我们还是需要提供`WebSecurityConfigurerAdapter`的实现类去完成: - -```java -@Configuration -public class SecurityConfiguration extends WebSecurityConfigurerAdapter { - - @Override - protected void configure(HttpSecurity http) throws Exception { - http - .authorizeRequests() - .antMatchers("/login").permitAll() - .anyRequest().hasRole("user") - .and() - .formLogin(); - } -} -``` - -注意这里不需要再添加`@EnableWebSecurity`了,因为starter依赖已经帮我们添加了。 - -使用了SpringBoot之后,我们发现,需要什么功能,只需要导入对应的starter依赖即可,甚至都不需要你去进行额外的配置,你只需要关注依赖本身的必要设置即可,大大提高了我们的开发效率。 - -*** - -## 整合Mybatis框架 - -我们接着来看如何整合Mybatis框架,同样的,我们只需要导入对应的starter依赖即可: - -```xml - - org.mybatis.spring.boot - mybatis-spring-boot-starter - 2.2.0 - - - mysql - mysql-connector-java - -``` - -导入依赖后,直接启动会报错,是因为有必要的配置我们没有去编写,我们需要指定数据源的相关信息: - -```yaml -spring: - datasource: - url: jdbc:mysql://localhost:3306 - username: root - password: 123456 - driver-class-name: com.mysql.cj.jdbc.Driver -``` - -再次启动,成功。 - -我们发现日志中会出现这样一句话: - -``` -2022-01-07 12:32:09.106 WARN 6917 --- [ main] o.m.s.mapper.ClassPathMapperScanner : No MyBatis mapper was found in '[com.example]' package. Please check your configuration. -``` - -这是Mybatis自动扫描输出的语句,导入依赖后,我们不需要再去设置Mybatis的相关Bean了,也不需要添加任何`@MapperSacn`注解,因为starter已经帮助我们做了,它会自动扫描项目中添加了`@Mapper`注解的接口,直接将其注册为Bean,不需要进行任何配置。 - -```java -@Mapper -public interface MainMapper { - @Select("select * from users where username = #{username}") - UserData findUserByName(String username); -} -``` - -当然,如果你觉得每个接口都去加一个`@Mapper`比较麻烦的话也可以用回之前的方式,直接`@MapperScan`使用包扫描。 - -添加Mapper之后,使用方法和SSM阶段是一样的,我们可以将其与SpringSecurity结合使用: - -```java -@Service -public class UserAuthService implements UserDetailsService { - - @Resource - MainMapper mapper; - - @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - UserData data = mapper.findUserByName(username); - if(data == null) throw new UsernameNotFoundException("用户 "+username+" 登录失败,用户名不存在!"); - return User - .withUsername(data.getUsername()) - .password(data.getPassword()) - .roles(data.getRole()) - .build(); - } -} -``` - -最后配置一下自定义验证即可,注意这样之前配置文件里面配置的用户就失效了: - -```java -@Override -protected void configure(AuthenticationManagerBuilder auth) throws Exception { - auth - .userDetailsService(service) - .passwordEncoder(new BCryptPasswordEncoder()); -} -``` - -在首次使用时,我们发现日志中输出以以下语句: - -``` -2022-01-07 12:39:40.559 INFO 6930 --- [nio-8080-exec-3] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting... -2022-01-07 12:39:41.033 INFO 6930 --- [nio-8080-exec-3] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed. -``` - -实际上,SpringBoot会自动为Mybatis配置数据源,默认使用的就是`HikariCP`数据源。 - -*** - -## 整合Thymeleaf框架 - -整合Thymeleaf也只需导入对应的starter即可: - -```xml - - org.springframework.boot - spring-boot-starter-thymeleaf - -``` - -接着我们只需要直接使用即可: - -```java -@RequestMapping("/index") -public String index(){ - return "index"; -} -``` - -但是注意,这样只能正常解析HTML页面,但是js、css等静态资源我们需要进行路径指定,不然无法访问,我们在配文件中配置一下静态资源的访问前缀: - -```yaml -spring: - mvc: - static-path-pattern: /static/** -``` - -接着我们像之前一样,把登陆页面实现一下吧。 - -```html - -``` - -*** - -## 日志系统 - -SpringBoot为我们提供了丰富的日志系统,它几乎是开箱即用的。 - -### 日志门面和日志实现 - -我们首先要区分一下,什么是日志门面(Facade)什么是日志实现,我们之前学习的JUL实际上就是一种日志实现,我们可以直接使用JUL为我们提供的日志框架来规范化打印日志,而日志门面,如Slf4j,是把不同的日志系统的实现进行了具体的抽象化,只提供了统一的日志使用接口,使用时只需要按照其提供的接口方法进行调用即可,由于它只是一个接口,并不是一个具体的可以直接单独使用的日志框架,所以最终日志的格式、记录级别、输出方式等都要通过接口绑定的具体的日志系统来实现,这些具体的日志系统就有log4j、logback、java.util.logging等,它们才实现了具体的日志系统的功能。 - -日志门面和日志实现就像JDBC和数据库驱动一样,一个是画大饼的,一个是真的去做饼的。 - -![img](https://upload-images.jianshu.io/upload_images/2909474-b5127a18b3eda3ec.png?imageMogr2/auto-orient/strip|imageView2/2/w/888) - -但是现在有一个问题就是,不同的框架可能使用了不同的日志框架,如果这个时候出现众多日志框架并存的情况,我们现在希望的是所有的框架一律使用日志门面(Slf4j)进行日志打印,这时该怎么去解决?我们不可能将其他框架依赖的日志框架替换掉,直接更换为Slf4j吧,这样显然不现实。 - -这时,可以采取类似于偷梁换柱的做法,只保留不同日志框架的接口和类定义等关键信息,而将实现全部定向为Slf4j调用。相当于有着和原有日志框架一样的外壳,对于其他框架来说依然可以使用对应的类进行操作,而具体如何执行,真正的内心已经是Slf4j的了。 - -![img](https://upload-images.jianshu.io/upload_images/2909474-512f5cca92e05e59.png?imageMogr2/auto-orient/strip|imageView2/2/w/928) - -所以,SpringBoot为了统一日志框架的使用,做了这些事情: - -* 直接将其他依赖以前的日志框架剔除 -* 导入对应日志框架的Slf4j中间包 -* 导入自己官方指定的日志实现,并作为Slf4j的日志实现层 - -### 在SpringBoot中打印日志信息 - -SpringBoot使用的是Slf4j作为日志门面,Logback([Logback](http://logback.qos.ch/) 是log4j 框架的作者开发的新一代日志框架,它效率更高、能够适应诸多的运行环境,同时天然支持SLF4J)作为日志实现,对应的依赖为: - -```xml - - org.springframework.boot - spring-boot-starter-logging - -``` - -此依赖已经被包含了,所以我们如果需要打印日志,可以像这样: - -```java -@RequestMapping("/login") -public String login(){ - Logger logger = LoggerFactory.getLogger(MainController.class); - logger.info("用户访问了一次登陆界面"); - return "login"; -} -``` - -因为我们使用了Lombok,所以直接一个注解也可以搞定哦: - -```java -@Slf4j -@Controller -public class MainController { - - @RequestMapping("/login") - public String login(){ - log.info("用户访问了一次登陆界面"); - return "login"; - } -``` - -日志级别从低到高分为TRACE < DEBUG < INFO < WARN < ERROR < FATAL,SpringBoot默认只会打印INFO以上级别的信息。 - -### 配置Logback日志 - -Logback官网:https://logback.qos.ch - -和JUL一样,Logback也能实现定制化,我们可以编写对应的配置文件,SpringBoot推荐将配置文件名称命名为`logback-spring.xml`表示这是SpringBoot下Logback专用的配置,可以使用SpringBoot 的高级Profile功能,它的内容类似于这样: - -```xml - - - - -``` - -最外层由`configuration`包裹,一旦编写,那么就会替换默认的配置,所以如果内部什么都不写的话,那么会导致我们的SpringBoot项目没有配置任何日志输出方式,控制台也不会打印日志。 - -我们接着来看如何配置一个控制台日志打印,我们可以直接导入并使用SpringBoot为我们预设好的日志格式,在`org/springframework/boot/logging/logback/defaults.xml`中已经帮我们把日志的输出格式定义好了,我们只需要设置对应的`appender`即可: - -```xml - - - - - - - - - - - - - - - - - - - -``` - -导入后,我们利用预设的日志格式创建一个控制台日志打印: - -```xml - - - - - - - - - - ${CONSOLE_LOG_PATTERN} - ${CONSOLE_LOG_CHARSET} - - - - - - - - -``` - -配置完成后,我们发现控制台已经可以正常打印日志信息了。 - -接着我们来看看如何开启文件打印,我们只需要配置一个对应的Appender即可: - -```xml - - - - ${FILE_LOG_PATTERN} - ${FILE_LOG_CHARSET} - - - - - log/%d{yyyy-MM-dd}-spring-%i.log - - true - - 7 - - 10MB - - - - - - - - -``` - -配置完成后,我们可以看到日志文件也能自动生成了。 - -我们也可以魔改官方提供的日志格式,官方文档:https://logback.qos.ch/manual/layouts.html - -这里需要提及的是MDC机制,Logback内置的日志字段还是比较少,如果我们需要打印有关业务的更多的内容,包括自定义的一些数据,需要借助logback MDC机制,MDC为“Mapped Diagnostic Context”(映射诊断上下文),即将一些运行时的上下文数据通过logback打印出来;此时我们需要借助org.sl4j.MDC类。 - -比如我们现在需要记录是哪个用户访问我们网站的日志,只要是此用户访问我们网站,都会在日志中携带该用户的ID,我们希望每条日志中都携带这样一段信息文本,而官方提供的字段无法实现此功能,这时就需要使用MDC机制: - -```java -@Slf4j -@Controller -public class MainController { - - @RequestMapping("/login") - public String login(){ - //这里就用Session代替ID吧 - MDC.put("reqId", request.getSession().getId()); - log.info("用户访问了一次登陆界面"); - return "login"; - } -``` - -通过这种方式,我们就可以向日志中传入自定义参数了,我们日志中添加这样一个占位符`%X{键值}`,名字保持一致: - -```xml - %clr([%X{reqId}]){faint} -``` - -这样当我们向MDC中添加信息后,只要是当前线程(本质是ThreadLocal实现)下输出的日志,都会自动替换占位符。 - -### 自定义Banner - -我们在之前发现,实际上Banner部分和日志部分是独立的,SpringBoot启动后,会先打印Banner部分,那么这个Banner部分是否可以自定义呢?答案是可以的。 - -我们可以直接来配置文件所在目录下创建一个名为`banner.txt`的文本文档,内容随便你: - -```txt -// _ooOoo_ // -// o8888888o // -// 88" . "88 // -// (| ^_^ |) // -// O\ = /O // -// ____/`---'\____ // -// .' \\| |// `. // -// / \\||| : |||// \ // -// / _||||| -:- |||||- \ // -// | | \\\ - /// | | // -// | \_| ''\---/'' | | // -// \ .-\__ `-` ___/-. / // -// ___`. .' /--.--\ `. . ___ // -// ."" '< `.___\_<|>_/___.' >'"". // -// | | : `- \`.;`\ _ /`;.`/ - ` : | | // -// \ \ `-. \_ __\ /__ _/ .-` / / // -// ========`-.____`-.___\_____/___.-`____.-'======== // -// `=---=' // -// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // -// 佛祖保佑 永无BUG 永不修改 // -``` - -可以使用在线生成网站进行生成自己的个性Banner:https://www.bootschool.net/ascii - -我们甚至还可以使用颜色代码来为文本切换颜色: - -``` -${AnsiColor.BRIGHT_GREEN} //绿色 -``` - -也可以获取一些常用的变量信息: - -``` -${AnsiColor.YELLOW} 当前 Spring Boot 版本:${spring-boot.version} -``` - -玩的开心! - -*** - -## 多环境配置 - -在日常开发中,我们项目会有多个环境。例如开发环境(develop)也就是我们研发过程中疯狂敲代码修BUG阶段,生产环境(production )项目开发得差不多了,可以放在服务器上跑了。不同的环境下,可能我们的配置文件也存在不同,但是我们不可能切换环境的时候又去重新写一次配置文件,所以我们可以将多个环境的配置文件提前写好,进行自由切换。 - -由于SpringBoot只会读取`application.properties`或是`application.yml`文件,那么怎么才能实现自由切换呢?SpringBoot给我们提供了一种方式,我们可以通过配置文件指定: - -```yaml -spring: - profiles: - active: dev -``` - -接着我们分别创建两个环境的配置文件,`application-dev.yml`和`application-prod.yml`分别表示开发环境和生产环境的配置文件,比如开发环境我们使用的服务器端口为8080,而生产环境下可能就需要设置为80或是443端口,那么这个时候就需要不同环境下的配置文件进行区分: - -```yaml -server: - port: 8080 -``` - -```yaml -server: - port: 80 -``` - -这样我们就可以灵活切换生产环境和开发环境下的配置文件了。 - -SpringBoot自带的Logback日志系统也是支持多环境配置的,比如我们想在开发环境下输出日志到控制台,而生产环境下只需要输出到文件即可,这时就需要进行环境配置: - -```xml - - - - - - - - - - - - -``` - -注意`springProfile`是区分大小写的! - -那如果我们希望生产环境中不要打包开发环境下的配置文件呢,我们目前虽然可以切换开发环境,但是打包的时候依然是所有配置文件全部打包,这样总感觉还欠缺一点完美,因此,打包的问题就只能找Maven解决了,Maven也可以设置多环境: - -```xml - - - - - dev - - true - - - dev - - - - - prod - - false - - - prod - - - -``` - -接着,我们需要根据环境的不同,排除其他环境的配置文件: - -```xml - - - - src/main/resources - - - - application*.yml - - - - - - src/main/resources - - true - - application.yml - - application-${environment}.yml - - - -``` - -接着,我们可以直接将Maven中的`environment`属性,传递给SpringBoot的配置文件,在构建时替换为对应的值: - -```yaml -spring: - profiles: - active: '@environment@' #注意YAML配置文件需要加单引号,否则会报错 -``` - -这样,根据我们Maven环境的切换,SpringBoot的配置文件也会进行对应的切换。 - -最后我们打开Maven栏目,就可以自由切换了,直接勾选即可,注意切换环境之后要重新加载一下Maven项目,不然不会生效! - -*** - -## 打包运行 - -现在我们的SpringBoot项目编写完成了,那么如何打包运行呢?非常简单,只需要点击Maven生命周期中的`package`即可,它会自动将其打包为可直接运行的Jar包,第一次打包可能会花费一些时间下载部分依赖的源码一起打包进Jar文件。 - -我们发现在打包的过程中还会完整的将项目跑一遍进行测试,如果我们不想测试直接打包,可以手动使用以下命令: - -```shell -mvn package -DskipTests -``` - -打包后,我们会直接得到一个名为`springboot-study-0.0.1-SNAPSHOT.jar`的文件,这时在CMD窗口中输入命令: - -```shell -java -jar springboot-study-0.0.1-SNAPSHOT.jar -``` - -输入后,可以看到我们的Java项目成功运行起来了,如果手动关闭窗口会导致整个项目终止运行。 - -*** - -## 再谈Spring框架 - -**注意:**开始本部分前,建议先完成SSM阶段的Spring源码讲解部分。 - -我们在SpringBoot阶段,需要继续扩充Spring框架的相关知识,来巩固和强化对于Spring框架的认识。 - -### 任务调度 - -为了执行某些任务,我们可能需要一些非常规的操作,比如我们希望使用多线程来处理我们的结果或是执行一些定时任务,到达指定时间再去执行。 - -这时我们首先想到的就是创建一个新的线程来处理,或是使用TimerTask来完成定时任务,但是我们有了Spring框架之后,就不用这样了,因为Spring框架为我们提供了更加便捷的方式进行任务调度。 - -#### 异步任务 - -需要使用Spring异步任务支持,我们需要在配置类上添加`@EnableAsync`或是在SpringBoot的启动类上添加也可以。 - -```java -@EnableAsync -@SpringBootApplication -public class SpringBootWebTestApplication { - public static void main(String[] args) { - SpringApplication.run(SpringBootWebTestApplication.class, args); - } -} -``` - -接着我们只需要在需要异步执行的方法上,添加`@Async`注解即可将此方法标记为异步,当此方法被调用时,会异步执行,也就是新开一个线程执行,不是在当前线程执行。 - -```java -@Service -public class TestService { - - @Async - public void test(){ - try { - Thread.sleep(3000); - System.out.println("我是异步任务!"); - } catch (InterruptedException e) { - e.printStackTrace(); - } - } -} -``` - -```java -@RequestMapping("/login") -public String login(HttpServletRequest request){ - service.test(); - System.out.println("我是同步任务!"); - return "login"; -} -``` - -实际上这也是得益于AOP机制,通过线程池实现,但是也要注意,正是因为它是AOP机制的产物,所以它只能是在Bean中才会生效! - -使用 @Async 注释的方法可以返回 'void' 或 "Future" 类型,Future是一种用于接收任务执行结果的一种类型,我们会在Java并发编程中进行讲解,这里暂时不做介绍。 - -#### 定时任务 - -看完了异步任务,我们接着来看定时任务,定时任务其实就是指定在哪个时候再去执行,在JavaSE阶段我们使用过TimerTask来执行定时任务。 - -Spring中的定时任务是全局性质的,当我们的Spring程序启动后,那么定时任务也就跟着启动了,我们可以在配置类上添加`@EnableScheduling`或是在SpringBoot的启动类上添加也可: - -```java -@EnableAsync -@EnableScheduling -@SpringBootApplication -public class SpringBootWebTestApplication { - public static void main(String[] args) { - SpringApplication.run(SpringBootWebTestApplication.class, args); - } -} -``` - -接着我们可以创建一个定时任务配置类,在配置类里面编写定时任务: - -```java -@Configuration -public class ScheduleConfiguration { - - @Scheduled(fixedRate = 2000) - public void task(){ - System.out.println("我是定时任务!"+new Date()); - } -} -``` - -我们注意到` @Scheduled`中有很多参数,我们需要指定'cron', 'fixedDelay(String)', or 'fixedRate(String)'的其中一个,否则无法创建定时任务,他们的区别如下: - -* fixedDelay:在上一次定时任务执行完之后,间隔多久继续执行。 -* fixedRate:无论上一次定时任务有没有执行完成,两次任务之间的时间间隔。 -* cron:使用cron表达式来指定任务计划。 - -这里重点讲解一下cron表达式:https://blog.csdn.net/sunnyzyq/article/details/98597252 - -### 监听器 - -监听器对我们来说也是一个比较陌生的概念,那么何谓监听呢? - -监听实际上就是等待某个事件的触发,当事件触发时,对应事件的监听器就会被通知。 - -```java -@Component -public class TestListener implements ApplicationListener { - @Override - public void onApplicationEvent(ContextRefreshedEvent event) { - System.out.println(event.getApplicationContext()); - } -} -``` - -通过监听事件,我们就可以在对应的时机进行一些额外的处理,我们可以通过断点调试来查看一个事件是如何发生,以及如何通知监听器的。 - -通过阅读源码,我们得知,一个事件实际上就是通过`publishEvent`方法来进行发布的,我们也可以自定义我们自己项目中的事件,并注册对应的监听器进行处理。 - -```java -public class TestEvent extends ApplicationEvent { //需要继承ApplicationEvent - public TestEvent(Object source) { - super(source); - } -} -``` - -```java -@Component -public class TestListener implements ApplicationListener { - - @Override - public void onApplicationEvent(TestEvent event) { - System.out.println("自定义事件发生了:"+event.getSource()); - } -} -``` - -```java -@Resource -ApplicationContext context; - -@RequestMapping("/login") -public String login(HttpServletRequest request){ - context.publishEvent(new TestEvent("有人访问了登录界面!")); - return "login"; -} -``` - -这样,我们就实现了自定义事件发布和监听。 - -### Aware系列接口 - -我们在之前讲解Spring源码时,经常会发现某些类的定义上,除了我们当时讲解的继承关系以外,还实现了一些接口,他们的名称基本都是`xxxxAware`,比如我们在讲解SpringSecurity的源码中,AbstractAuthenticationProcessingFilter类就是这样: - -```java -public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware { - protected ApplicationEventPublisher eventPublisher; - protected AuthenticationDetailsSource authenticationDetailsSource = new WebAuthenticationDetailsSource(); - private AuthenticationManager authenticationManager; - ... -``` - -我们发现它除了继承自GenericFilterBean之外,还实现了ApplicationEventPublisherAware和MessageSourceAware接口,那么这些Aware接口到底是干嘛的呢? - -Aware的中文意思为**感知**。简单来说,他就是一个标识,实现此接口的类会获得某些感知能力,Spring容器会在Bean被加载时,根据类实现的感知接口,会调用类中实现的对应感知方法。 - -比如AbstractAuthenticationProcessingFilter就实现了ApplicationEventPublisherAware接口,此接口的感知功能为事件发布器,在Bean加载时,会调用实现类中的`setApplicationEventPublisher`方法,而AbstractAuthenticationProcessingFilter类则利用此方法,在Bean加载阶段获得了容器的事件发布器,以便之后发布事件使用。 - -```java -public void setApplicationEventPublisher(ApplicationEventPublisher eventPublisher) { - this.eventPublisher = eventPublisher; //直接存到成员变量 -} -``` - -```java -protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { - SecurityContext context = SecurityContextHolder.createEmptyContext(); - context.setAuthentication(authResult); - SecurityContextHolder.setContext(context); - if (this.logger.isDebugEnabled()) { - this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult)); - } - - this.rememberMeServices.loginSuccess(request, response, authResult); - //在这里使用 - if (this.eventPublisher != null) { - this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass())); - } - - this.successHandler.onAuthenticationSuccess(request, response, authResult); -} -``` - -同样的,除了ApplicationEventPublisherAware接口外,我们再来演示一个接口,比如: - -```java -@Service -public class TestService implements BeanNameAware { - @Override - public void setBeanName(String s) { - System.out.println(s); - } -} -``` - -BeanNameAware就是感知Bean名称的一个接口,当Bean被加载时,会调用`setBeanName`方法并将Bean名称作为参数传递。 - -有关所有的Aware这里就不一一列举了。 - -*** - -## 探究SpringBoot实现原理 - -**注意:**难度较大,本版块作为选学内容,在开始前,必须完成SSM阶段源码解析部分的学习。 - -我们在前面的学习中切实感受到了SpringBoot为我们带来的便捷,那么它为何能够实现如此快捷的开发模式,starter又是一个怎样的存在,它是如何进行自动配置的,我们现在就开始研究。 - -### 启动原理 - -首先我们来看看,SpringBoot项目启动之后,做了什么事情,SpringApplication中的静态`run`方法: - -```java -public static ConfigurableApplicationContext run(Class primarySource, String... args) { - return run(new Class[]{primarySource}, args); -} -``` - -套娃如下: - -```java -public static ConfigurableApplicationContext run(Class[] primarySources, String[] args) { - return (new SpringApplication(primarySources)).run(args); -} -``` - -我们发现,这里直接new了一个新的SpringApplication对象,传入我们的主类作为构造方法参数,并调用了非static的`run`方法,我们先来看看构造方法里面做了什么事情: - -```java -public SpringApplication(ResourceLoader resourceLoader, Class... primarySources) { - ... - this.resourceLoader = resourceLoader; - Assert.notNull(primarySources, "PrimarySources must not be null"); - this.primarySources = new LinkedHashSet(Arrays.asList(primarySources)); - //这里是关键,这里会判断当前SpringBoot应用程序是否为Web项目,并返回当前的项目类型 - //deduceFromClasspath是根据类路径下判断是否包含SpringBootWeb依赖,如果不包含就是NONE类型,包含就是SERVLET类型 - this.webApplicationType = WebApplicationType.deduceFromClasspath(); - this.bootstrapRegistryInitializers = new ArrayList(this.getSpringFactoriesInstances(BootstrapRegistryInitializer.class)); - //创建所有ApplicationContextInitializer实现类的对象 - this.setInitializers(this.getSpringFactoriesInstances(ApplicationContextInitializer.class)); - this.setListeners(this.getSpringFactoriesInstances(ApplicationListener.class)); - this.mainApplicationClass = this.deduceMainApplicationClass(); -} -``` - -关键就在这里了,它是如何知道哪些类是ApplicationContextInitializer的实现类的呢? - -这里就要提到spring.factories了,它是 Spring 仿造Java SPI实现的一种类加载机制。它在 META-INF/spring.factories 文件中配置接口的实现类名称,然后在程序中读取这些配置文件并实例化。这种自定义的SPI机制是 Spring Boot Starter 实现的基础。 - -SPI的常见例子: - -- 数据库驱动加载接口实现类的加载:JDBC加载不同类型数据库的驱动 -- 日志门面接口实现类加载:SLF4J加载不同提供商的日志实现类 - -说白了就是人家定义接口,但是实现可能有很多种,但是核心只提供接口,需要我们按需选择对应的实现,这种方式是高度解耦的。 - -我们来看看`getSpringFactoriesInstances`方法做了什么: - -```java -private Collection getSpringFactoriesInstances(Class type, Class[] parameterTypes, Object... args) { - //获取当前的类加载器 - ClassLoader classLoader = this.getClassLoader(); - //获取所有依赖中 META-INF/spring.factories 中配置的对应接口类的实现类列表 - Set names = new LinkedHashSet(SpringFactoriesLoader.loadFactoryNames(type, classLoader)); - //根据上方列表,依次创建实例对象 - List instances = this.createSpringFactoriesInstances(type, parameterTypes, classLoader, args, names); - //根据对应类上的Order接口或是注解进行排序 - AnnotationAwareOrderComparator.sort(instances); - //返回实例 - return instances; -} -``` - -其中`SpringFactoriesLoader.loadFactoryNames`正是读取配置的核心部分,我们后面还会遇到。 - -接着我们来看run方法里面做了什么事情。 - -```java -public ConfigurableApplicationContext run(String... args) { - long startTime = System.nanoTime(); - DefaultBootstrapContext bootstrapContext = this.createBootstrapContext(); - ConfigurableApplicationContext context = null; - this.configureHeadlessProperty(); - //获取所有的SpringApplicationRunListener,并通知启动事件,默认只有一个实现类EventPublishingRunListener - //EventPublishingRunListener会将初始化各个阶段的事件转发给所有监听器 - SpringApplicationRunListeners listeners = this.getRunListeners(args); - listeners.starting(bootstrapContext, this.mainApplicationClass); - - try { - //环境配置 - ApplicationArguments applicationArguments = new DefaultApplicationArguments(args); - ConfigurableEnvironment environment = this.prepareEnvironment(listeners, bootstrapContext, applicationArguments); - this.configureIgnoreBeanInfo(environment); - //打印Banner - Banner printedBanner = this.printBanner(environment); - //创建ApplicationContext,注意这里会根据是否为Web容器使用不同的ApplicationContext实现类 - context = this.createApplicationContext(); - context.setApplicationStartup(this.applicationStartup); - //初始化ApplicationContext - this.prepareContext(bootstrapContext, context, environment, listeners, applicationArguments, printedBanner); - //执行ApplicationContext的refresh方法 - this.refreshContext(context); - this.afterRefresh(context, applicationArguments); - Duration timeTakenToStartup = Duration.ofNanos(System.nanoTime() - startTime); - if (this.logStartupInfo) { - (new StartupInfoLogger(this.mainApplicationClass)).logStarted(this.getApplicationLog(), timeTakenToStartup); - } - .... -} -``` - -我们发现,实际上SpringBoot就是Spring的一层壳罢了,离不开最关键的ApplicationContext,也就是说,在启动后会自动配置一个ApplicationContext,只不过是进行了大量的扩展。 - -我们来看ApplicationContext是怎么来的,打开`createApplicationContext`方法: - -```java -protected ConfigurableApplicationContext createApplicationContext() { - return this.applicationContextFactory.create(this.webApplicationType); -} -``` - -我们发现在构造方法中`applicationContextFactory`直接使用的是DEFAULT: - -```java -this.applicationContextFactory = ApplicationContextFactory.DEFAULT; -``` - -```java -ApplicationContextFactory DEFAULT = (webApplicationType) -> { - try { - switch(webApplicationType) { - case SERVLET: - return new AnnotationConfigServletWebServerApplicationContext(); - case REACTIVE: - return new AnnotationConfigReactiveWebServerApplicationContext(); - default: - return new AnnotationConfigApplicationContext(); - } - } catch (Exception var2) { - throw new IllegalStateException("Unable create a default ApplicationContext instance, you may need a custom ApplicationContextFactory", var2); - } -}; - -ConfigurableApplicationContext create(WebApplicationType webApplicationType); -``` - -DEFAULT是直接编写的一个匿名内部类,其实已经很明确了,正是根据`webApplicationType`类型进行判断,如果是SERVLET,那么久返回专用于Web环境的AnnotationConfigServletWebServerApplicationContext对象(SpringBoot中新增的),否则返回普通的AnnotationConfigApplicationContext对象,也就是到这里为止,Spring的容器就基本已经确定了。 - -注意AnnotationConfigApplicationContext是Spring框架提供的类,从这里开始相当于我们在讲Spring的底层源码了,我们继续深入,AnnotationConfigApplicationContext对象在创建过程中会创建`AnnotatedBeanDefinitionReader`,它是用于通过注解解析Bean定义的工具类: - -```java -public AnnotationConfigApplicationContext() { - StartupStep createAnnotatedBeanDefReader = this.getApplicationStartup().start("spring.context.annotated-bean-reader.create"); - this.reader = new AnnotatedBeanDefinitionReader(this); - createAnnotatedBeanDefReader.end(); - this.scanner = new ClassPathBeanDefinitionScanner(this); -} -``` - -其构造方法: - -```java -public AnnotatedBeanDefinitionReader(BeanDefinitionRegistry registry, Environment environment) { - ... - //这里会注册很多的后置处理器 - AnnotationConfigUtils.registerAnnotationConfigProcessors(this.registry); -} -``` - -```java -public static Set registerAnnotationConfigProcessors(BeanDefinitionRegistry registry, @Nullable Object source) { - DefaultListableBeanFactory beanFactory = unwrapDefaultListableBeanFactory(registry); - .... - Set beanDefs = new LinkedHashSet(8); - RootBeanDefinition def; - if (!registry.containsBeanDefinition("org.springframework.context.annotation.internalConfigurationAnnotationProcessor")) { - //注册了ConfigurationClassPostProcessor用于处理@Configuration、@Import等注解 - //注意这里是关键,之后Selector还要讲到它 - //它是继承自BeanDefinitionRegistryPostProcessor,所以它的执行时间在Bean定义加载完成后,Bean初始化之前 - def = new RootBeanDefinition(ConfigurationClassPostProcessor.class); - def.setSource(source); - beanDefs.add(registerPostProcessor(registry, def, "org.springframework.context.annotation.internalConfigurationAnnotationProcessor")); - } - - if (!registry.containsBeanDefinition("org.springframework.context.annotation.internalAutowiredAnnotationProcessor")) { - //AutowiredAnnotationBeanPostProcessor用于处理@Value等注解自动注入 - def = new RootBeanDefinition(AutowiredAnnotationBeanPostProcessor.class); - def.setSource(source); - beanDefs.add(registerPostProcessor(registry, def, "org.springframework.context.annotation.internalAutowiredAnnotationProcessor")); - } - - ... -``` - -回到SpringBoot,我们最后来看,`prepareContext`方法中又做了什么事情: - -```java -private void prepareContext(DefaultBootstrapContext bootstrapContext, ConfigurableApplicationContext context, ConfigurableEnvironment environment, SpringApplicationRunListeners listeners, ApplicationArguments applicationArguments, Banner printedBanner) { - //环境配置 - context.setEnvironment(environment); - this.postProcessApplicationContext(context); - this.applyInitializers(context); - listeners.contextPrepared(context); - bootstrapContext.close(context); - if (this.logStartupInfo) { - this.logStartupInfo(context.getParent() == null); - this.logStartupProfileInfo(context); - } - - //将Banner注册为Bean - ConfigurableListableBeanFactory beanFactory = context.getBeanFactory(); - beanFactory.registerSingleton("springApplicationArguments", applicationArguments); - if (printedBanner != null) { - beanFactory.registerSingleton("springBootBanner", printedBanner); - } - - if (beanFactory instanceof AbstractAutowireCapableBeanFactory) { - ((AbstractAutowireCapableBeanFactory)beanFactory).setAllowCircularReferences(this.allowCircularReferences); - if (beanFactory instanceof DefaultListableBeanFactory) { - ((DefaultListableBeanFactory)beanFactory).setAllowBeanDefinitionOverriding(this.allowBeanDefinitionOverriding); - } - } - - if (this.lazyInitialization) { - context.addBeanFactoryPostProcessor(new LazyInitializationBeanFactoryPostProcessor()); - } - - //这里会获取我们一开始传入的项目主类 - Set sources = this.getAllSources(); - Assert.notEmpty(sources, "Sources must not be empty"); - //这里会将我们的主类直接注册为Bean,这样就可以通过注解加载了 - this.load(context, sources.toArray(new Object[0])); - listeners.contextLoaded(context); -} -``` - -因此,在`prepareContext`执行完成之后,我们的主类成功完成Bean注册,接下来,就该类上注解大显身手了。 - -### 自动配置原理 - -既然主类已经在初始阶段注册为Bean,那么在加载时,就会根据注解定义,进行更多的额外操作。所以我们来看看主类上的`@SpringBootApplication`注解做了什么事情。 - -```java -@Target({ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -@Documented -@Inherited -@SpringBootConfiguration -@EnableAutoConfiguration -@ComponentScan( - excludeFilters = {@Filter( - type = FilterType.CUSTOM, - classes = {TypeExcludeFilter.class} -), @Filter( - type = FilterType.CUSTOM, - classes = {AutoConfigurationExcludeFilter.class} -)} -) -public @interface SpringBootApplication { -``` - -我们发现,`@SpringBootApplication`上添加了`@ComponentScan`注解,此注解我们此前已经认识过了,但是这里并没有配置具体扫描的包,因此它会自动将声明此接口的类所在的包作为basePackage,因此当添加`@SpringBootApplication`之后也就等于直接开启了自动扫描,但是一定注意不能在主类之外的包进行Bean定义,否则无法扫描到,需要手动配置。 - -接着我们来看第二个注解`@EnableAutoConfiguration`,它就是自动配置的核心了,我们来看看它是如何定义的: - -```java -@Target({ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -@Documented -@Inherited -@AutoConfigurationPackage -@Import({AutoConfigurationImportSelector.class}) -public @interface EnableAutoConfiguration { -``` - -老套路了,直接一手`@Import`,通过这种方式来将一些外部的Bean加载到容器中。我们来看看AutoConfigurationImportSelector做了什么事情: - -```java -public class AutoConfigurationImportSelector implements DeferredImportSelector, BeanClassLoaderAware, ResourceLoaderAware, BeanFactoryAware, EnvironmentAware, Ordered { - ... -} -``` - -我们看到它实现了很多接口,包括大量的Aware接口,实际上就是为了感知某些必要的对象,并将其存到当前类中。 - -其中最核心的是`DeferredImportSelector`接口,它是`ImportSelector`的子类,它定义了`selectImports`方法,用于返回需要加载的类名称,在Spring加载ImportSelector类型的Bean时,会调用此方法来获取更多需要加载的类,并将这些类一并注册为Bean: - -```java -public interface ImportSelector { - String[] selectImports(AnnotationMetadata importingClassMetadata); - - @Nullable - default Predicate getExclusionFilter() { - return null; - } -} -``` - -到目前为止,我们了解了两种使用`@Import`有特殊机制的接口:ImportSelector(这里用到的)和ImportBeanDefinitionRegistrar(之前Mybatis-spring源码有讲)当然还有普通的`@Configuration`配置类。 - -我们可以来阅读一下`ConfigurationClassPostProcessor`的源码,看看它到底是如何处理`@Import`的: - -```java -public void processConfigBeanDefinitions(BeanDefinitionRegistry registry) { - List configCandidates = new ArrayList(); - //注意这个阶段仅仅是已经完成扫描了所有的Bean,得到了所有的BeanDefinition,但是还没有进行任何区分 - //candidate是候选者的意思,一会会将标记了@Configuration的类作为ConfigurationClass加入到configCandidates中 - String[] candidateNames = registry.getBeanDefinitionNames(); - String[] var4 = candidateNames; - int var5 = candidateNames.length; - - for(int var6 = 0; var6 < var5; ++var6) { - String beanName = var4[var6]; - BeanDefinition beanDef = registry.getBeanDefinition(beanName); - if (beanDef.getAttribute(ConfigurationClassUtils.CONFIGURATION_CLASS_ATTRIBUTE) != null) { - if (this.logger.isDebugEnabled()) { - this.logger.debug("Bean definition has already been processed as a configuration class: " + beanDef); - } - } else if (ConfigurationClassUtils.checkConfigurationClassCandidate(beanDef, this.metadataReaderFactory)) { //判断是否添加了@Configuration注解 - configCandidates.add(new BeanDefinitionHolder(beanDef, beanName)); - } - } - - if (!configCandidates.isEmpty()) { - //...省略 - - //这里创建了一个ConfigurationClassParser用于解析配置类 - ConfigurationClassParser parser = new ConfigurationClassParser(this.metadataReaderFactory, this.problemReporter, this.environment, this.resourceLoader, this.componentScanBeanNameGenerator, registry); - //所有配置类的BeanDefinitionHolder列表 - Set candidates = new LinkedHashSet(configCandidates); - //已经解析完成的类 - HashSet alreadyParsed = new HashSet(configCandidates.size()); - - do { - //这里省略,直到所有的配置类全部解析完成 - //注意在循环过程中可能会由于@Import新增更多的待解析配置类,一律丢进candidates集合中 - } while(!candidates.isEmpty()); - - ... - - } -} -``` - -我们接着来看,`ConfigurationClassParser`是如何进行解析的: - -```java -protected void processConfigurationClass(ConfigurationClass configClass, Predicate filter) throws IOException { - //@Conditional相关注解处理 - //后面会讲 - if (!this.conditionEvaluator.shouldSkip(configClass.getMetadata(), ConfigurationPhase.PARSE_CONFIGURATION)) { - ... - } - - ConfigurationClassParser.SourceClass sourceClass = this.asSourceClass(configClass, filter); - - do { - //核心 - sourceClass = this.doProcessConfigurationClass(configClass, sourceClass, filter); - } while(sourceClass != null); - - this.configurationClasses.put(configClass, configClass); - } -} -``` - -最后我们再来看最核心的`doProcessConfigurationClass`方法: - -```java -protected final SourceClass doProcessConfigurationClass(ConfigurationClass configClass, SourceClass sourceClass) - ... - - processImports(configClass, sourceClass, getImports(sourceClass), true); // 处理Import注解 - - ... - - return null; -} -``` - -```java -private void processImports(ConfigurationClass configClass, ConfigurationClassParser.SourceClass currentSourceClass, Collection importCandidates, Predicate exclusionFilter, boolean checkForCircularImports) { - if (!importCandidates.isEmpty()) { - if (checkForCircularImports && this.isChainedImportOnStack(configClass)) { - this.problemReporter.error(new ConfigurationClassParser.CircularImportProblem(configClass, this.importStack)); - } else { - this.importStack.push(configClass); - - try { - Iterator var6 = importCandidates.iterator(); - - while(var6.hasNext()) { - ConfigurationClassParser.SourceClass candidate = (ConfigurationClassParser.SourceClass)var6.next(); - Class candidateClass; - //如果是ImportSelector类型,继续进行运行 - if (candidate.isAssignable(ImportSelector.class)) { - candidateClass = candidate.loadClass(); - ImportSelector selector = (ImportSelector)ParserStrategyUtils.instantiateClass(candidateClass, ImportSelector.class, this.environment, this.resourceLoader, this.registry); - Predicate selectorFilter = selector.getExclusionFilter(); - if (selectorFilter != null) { - exclusionFilter = exclusionFilter.or(selectorFilter); - } - //如果是DeferredImportSelector的实现类,那么会走deferredImportSelectorHandler的handle方法 - if (selector instanceof DeferredImportSelector) { - this.deferredImportSelectorHandler.handle(configClass, (DeferredImportSelector)selector); - //否则就按照正常的ImportSelector类型进行加载 - } else { - //调用selectImports方法获取所有需要加载的类 - String[] importClassNames = selector.selectImports(currentSourceClass.getMetadata()); - Collection importSourceClasses = this.asSourceClasses(importClassNames, exclusionFilter); - //递归处理,直到没有 - this.processImports(configClass, currentSourceClass, importSourceClasses, exclusionFilter, false); - } - //判断是否为ImportBeanDefinitionRegistrar类型 - } else if (candidate.isAssignable(ImportBeanDefinitionRegistrar.class)) { - candidateClass = candidate.loadClass(); - ImportBeanDefinitionRegistrar registrar = (ImportBeanDefinitionRegistrar)ParserStrategyUtils.instantiateClass(candidateClass, ImportBeanDefinitionRegistrar.class, this.environment, this.resourceLoader, this.registry); - //往configClass丢ImportBeanDefinitionRegistrar信息进去,之后再处理 - configClass.addImportBeanDefinitionRegistrar(registrar, currentSourceClass.getMetadata()); - //否则按普通的配置类进行处理 - } else { - this.importStack.registerImport(currentSourceClass.getMetadata(), candidate.getMetadata().getClassName()); - this.processConfigurationClass(candidate.asConfigClass(configClass), exclusionFilter); - } - } - } catch (BeanDefinitionStoreException var17) { - throw var17; - } catch (Throwable var18) { - throw new BeanDefinitionStoreException("Failed to process import candidates for configuration class [" + configClass.getMetadata().getClassName() + "]", var18); - } finally { - this.importStack.pop(); - } - } - - } -} -``` - -不难注意到,虽然这里额外处理了`ImportSelector`对象,但是还针对`ImportSelector`的子接口`DeferredImportSelector`进行了额外处理,Deferred是延迟的意思,它是一个延迟执行的`ImportSelector`,并不会立即进处理,而是丢进DeferredImportSelectorHandler,并且在`parse`方法的最后进行处理: - -```java -public void parse(Set configCandidates) { - ... - - this.deferredImportSelectorHandler.process(); -} -``` - -我们接着来看`DeferredImportSelector`正好就有一个`process`方法: - -```java -public interface DeferredImportSelector extends ImportSelector { - @Nullable - default Class getImportGroup() { - return null; - } - - public interface Group { - void process(AnnotationMetadata metadata, DeferredImportSelector selector); - - Iterable selectImports(); - - public static class Entry { - ... -``` - -最后经过ConfigurationClassParser处理完成后,通过`parser.getConfigurationClasses()`就能得到通过配置类导入了哪些额外的配置类。最后将这些配置类全部注册BeanDefinition,然后就可以交给接下来的Bean初始化过程去处理了。 - -```java -this.reader.loadBeanDefinitions(configClasses); -``` - -最后我们再去看`loadBeanDefinitions`是如何运行的: - -```java -public void loadBeanDefinitions(Set configurationModel) { - ConfigurationClassBeanDefinitionReader.TrackedConditionEvaluator trackedConditionEvaluator = new ConfigurationClassBeanDefinitionReader.TrackedConditionEvaluator(); - Iterator var3 = configurationModel.iterator(); - - while(var3.hasNext()) { - ConfigurationClass configClass = (ConfigurationClass)var3.next(); - this.loadBeanDefinitionsForConfigurationClass(configClass, trackedConditionEvaluator); - } - -} - -private void loadBeanDefinitionsForConfigurationClass(ConfigurationClass configClass, ConfigurationClassBeanDefinitionReader.TrackedConditionEvaluator trackedConditionEvaluator) { - if (trackedConditionEvaluator.shouldSkip(configClass)) { - String beanName = configClass.getBeanName(); - if (StringUtils.hasLength(beanName) && this.registry.containsBeanDefinition(beanName)) { - this.registry.removeBeanDefinition(beanName); - } - - this.importRegistry.removeImportingClass(configClass.getMetadata().getClassName()); - } else { - if (configClass.isImported()) { - this.registerBeanDefinitionForImportedConfigurationClass(configClass); //注册配置类自己 - } - - Iterator var3 = configClass.getBeanMethods().iterator(); - - while(var3.hasNext()) { - BeanMethod beanMethod = (BeanMethod)var3.next(); - this.loadBeanDefinitionsForBeanMethod(beanMethod); //注册@Bean注解标识的方法 - } - - //注册`@ImportResource`引入的XML配置文件中读取的bean定义 - this.loadBeanDefinitionsFromImportedResources(configClass.getImportedResources()); - //注册configClass中经过解析后保存的所有ImportBeanDefinitionRegistrar,注册对应的BeanDefinition - this.loadBeanDefinitionsFromRegistrars(configClass.getImportBeanDefinitionRegistrars()); - } -} -``` - -这样,整个`@Configuration`配置类的底层配置流程我们就大致了解了。接着我们来看AutoConfigurationImportSelector是如何实现自动配置的,可以看到内部类AutoConfigurationGroup的process方法,它是父接口的实现,因为父接口是`DeferredImportSelector`,那么很容易得知,实际上最后会调用`process`方法获取所有的自动配置类: - -```java -public void process(AnnotationMetadata annotationMetadata, DeferredImportSelector deferredImportSelector) { - Assert.state(deferredImportSelector instanceof AutoConfigurationImportSelector, () -> { - return String.format("Only %s implementations are supported, got %s", AutoConfigurationImportSelector.class.getSimpleName(), deferredImportSelector.getClass().getName()); - }); - //获取所有的Entry,其实就是,读取spring.factories来查看有哪些自动配置类 - AutoConfigurationImportSelector.AutoConfigurationEntry autoConfigurationEntry = ((AutoConfigurationImportSelector)deferredImportSelector).getAutoConfigurationEntry(annotationMetadata); - this.autoConfigurationEntries.add(autoConfigurationEntry); - Iterator var4 = autoConfigurationEntry.getConfigurations().iterator(); - - while(var4.hasNext()) { - String importClassName = (String)var4.next(); - this.entries.putIfAbsent(importClassName, annotationMetadata); - } - -} -``` - -我们接着来看`getAutoConfigurationEntry`方法: - -```java -protected AutoConfigurationImportSelector.AutoConfigurationEntry getAutoConfigurationEntry(AnnotationMetadata annotationMetadata) { - //判断是否开启了自动配置,是的,自动配置可以关 - if (!this.isEnabled(annotationMetadata)) { - return EMPTY_ENTRY; - } else { - //根据注解定义获取一些属性 - AnnotationAttributes attributes = this.getAttributes(annotationMetadata); - //得到spring.factories文件中所有需要自动配置的类 - List configurations = this.getCandidateConfigurations(annotationMetadata, attributes); - ... 这里先看前半部分 - } -} -``` - -注意这里并不是spring.factories文件中所有的自动配置类都会被加载,它会根据@Condition注解的条件进行加载。这样就能实现我们需要什么模块添加对应依赖就可以实现自动配置了。 - -所有的源码看不懂,都源自于你的心中没有形成一个完整的闭环!一旦一条线推到头,闭环形成,所有疑惑迎刃而解。 - -### 自定义Starter - -我们仿照Mybatis来编写一个自己的starter,Mybatis的starter包含两个部分: - -```xml - - 4.0.0 - - org.mybatis.spring.boot - mybatis-spring-boot - 2.2.0 - - - mybatis-spring-boot-starter - mybatis-spring-boot-starter - - org.mybatis.spring.boot.starter - - - - org.springframework.boot - spring-boot-starter - - - org.springframework.boot - spring-boot-starter-jdbc - - - - org.mybatis.spring.boot - mybatis-spring-boot-autoconfigure - - - org.mybatis - mybatis - - - org.mybatis - mybatis-spring - - - -``` - -因此我们也将我们自己的starter这样设计: - -我们设计三个模块: - -* spring-boot-hello:基础业务功能模块 -* spring-boot-starter-hello:启动器 -* spring-boot-autoconifgurer-hello:自动配置依赖 - -首先是基础业务功能模块,这里我们随便创建一个类就可以了: - -```java -public class HelloWorldService { - -} -``` - -启动器主要做依赖管理,这里就不写任何代码,只写pom文件: - -```xml - - - org.example - spring-boot-autoconfigurer-hello - 1.0-SNAPSHOT - - -``` - -导入autoconfigurer模块作为依赖即可,接着我们去编写autoconfigurer模块,首先导入依赖: - -```xml - - - org.springframework.boot - spring-boot-autoconfigure - 2.6.2 - - - - org.springframework.boot - spring-boot-configuration-processor - 2.6.2 - true - - - - org.example - spring-boot-hello - 1.0-SNAPSHOT - - -``` - -接着创建一个HelloWorldAutoConfiguration作为自动配置类: - -```java -@Configuration(proxyBeanMethods = false) -@ConditionalOnWebApplication -@ConditionalOnClass(HelloWorldService.class) -@EnableConfigurationProperties(HelloWorldProperties.class) -public class HelloWorldAutoConfiguration { - - Logger logger = Logger.getLogger(this.getClass().getName()); - - @Resource - HelloWorldProperties properties; - - @Bean - public HelloWorldService helloWorldService(){ - logger.info("自定义starter项目已启动!"); - logger.info("读取到自定义配置:"+properties.getValue()); - return new HelloWorldService(); - } -} -``` - -对应的配置读取类: - -```java -@ConfigurationProperties("hello.world") -public class HelloWorldProperties { - - private String value; - - public void setValue(String value) { - this.value = value; - } - - public String getValue() { - return value; - } -} -``` - -最后再编写`spring.factories`文件,并将我们的自动配置类添加即可: - -```properties -org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ - com.hello.autoconfigurer.HelloWorldAutoConfiguration -``` - -最后再Maven根项目执行`install`安装到本地仓库,完成。接着就可以在其他项目中使用我们编写的自定义starter了。 - -### Runner接口 - -在项目中,可能会遇到这样一个问题:我们需要在项目启动完成之后,紧接着执行一段代码。 - -我们可以编写自定义的ApplicationRunner来解决,它会在项目启动完成后执行: - -```java -@Component -public class TestRunner implements ApplicationRunner { - @Override - public void run(ApplicationArguments args) throws Exception { - System.out.println("我是自定义执行!"); - } -} -``` - -当然也可以使用CommandLineRunner,它也支持使用@Order或是实现Ordered接口来支持优先级执行。 - -实际上它就是run方法的最后: - -```java -public ConfigurableApplicationContext run(String... args) { - .... - - listeners.started(context, timeTakenToStartup); - //这里已经完成整个SpringBoot项目启动,所以执行所有的Runner - this.callRunners(context, applicationArguments); - } catch (Throwable var12) { - this.handleRunFailure(context, var12, listeners); - throw new IllegalStateException(var12); - } - - try { - Duration timeTakenToReady = Duration.ofNanos(System.nanoTime() - startTime); - listeners.ready(context, timeTakenToReady); - return context; - } catch (Throwable var11) { - this.handleRunFailure(context, var11, (SpringApplicationRunListeners)null); - throw new IllegalStateException(var11); - } -} -``` - -下一章,我们将继续讲解几乎程序员必会的Git版本控制。 diff --git a/青空笔记/SpringBoot笔记/SpringBoot笔记(三).md b/青空笔记/SpringBoot笔记/SpringBoot笔记(三).md deleted file mode 100644 index 1beaa85..0000000 --- a/青空笔记/SpringBoot笔记/SpringBoot笔记(三).md +++ /dev/null @@ -1,1019 +0,0 @@ -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fpic4.zhimg.com%2Fv2-054d8ff6135b3638aca543eff7424f98_1200x500.jpg&refer=http%3A%2F%2Fpic4.zhimg.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1644500043&t=72a4f8ecfca9ff5a2a0b3896edef4be7) - -# Redis数据库 - -**灵魂拷问:**不是学了MySQL吗,存数据也能存了啊,又学一个数据库干嘛? - -在前面我们学习了MySQL数据库,它是一种传统的关系型数据库,我们可以使用MySQL来更好地管理和组织我们的数据,虽然在小型Web应用下,只需要一个MySQL+Mybatis自带的缓存系统就可以胜任大部分的数据存储工作。但是MySQL的缺点也很明显,它的数据始终是存储在硬盘上的,对于我们的用户信息这种不需要经常发生修改的内容,使用MySQL存储确实可以,但是如果是快速更新或是频繁使用的数据,比如微博热搜、双十一秒杀,这些数据不仅要求服务器需要提供更高的响应速度,而且还需要面对短时间内上百万甚至上千万次访问,而MySQL的磁盘IO读写性能完全不能满足上面的需求,能够满足上述需求的只有内存,因为速度远高于磁盘IO。 - -因此,我们需要寻找一种更好的解决方案,来存储上述这类特殊数据,弥补MySQL的不足,以应对大数据时代的重重考验。 - -## NoSQL概论 - -NoSQL全称是Not Only SQL(不仅仅是SQL)它是一种非关系型数据库,相比传统SQL关系型数据库,它: - -* 不保证关系数据的ACID特性 -* 并不遵循SQL标准 -* 消除数据之间关联性 - -乍一看,这玩意不比MySQL垃圾?我们再来看看它的优势: - -* 远超传统关系型数据库的性能 -* 非常易于扩展 -* 数据模型更加灵活 -* 高可用 - -这样,NoSQL的优势一下就出来了,这不就是我们正要寻找的高并发海量数据的解决方案吗! - -NoSQL数据库分为以下几种: - -* **键值存储数据库:**所有的数据都是以键值方式存储的,类似于我们之前学过的HashMap,使用起来非常简单方便,性能也非常高。 -* **列存储数据库:**这部分数据库通常是用来应对分布式存储的海量数据。键仍然存在,但是它们的特点是指向了多个列。 -* **文档型数据库:**它是以一种特定的文档格式存储数据,比如JSON格式,在处理网页等复杂数据时,文档型数据库比传统键值数据库的查询效率更高。 -* **图形数据库:**利用类似于图的数据结构存储数据,结合图相关算法实现高速访问。 - -其中我们要学习的Redis数据库,就是一个开源的**键值存储数据库**,所有的数据全部存放在内存中,它的性能大大高于磁盘IO,并且它也可以支持数据持久化,他还支持横向扩展、主从复制等。 - -实际生产中,我们一般会配合使用Redis和MySQL以发挥它们各自的优势,取长补短。 - -## Redis安装和部署 - -我们这里还是使用Windows安装Redis服务器,但是官方指定是安装到Linux服务器上,我们后面学习了Linux之后,再来安装到Linux服务器上。由于官方并没有提供Windows版本的安装包,我们需要另外寻找: - -* 官网地址:https://redis.io -* GitHub Windows版本维护地址:https://github.com/tporadowski/redis/releases - -*** - -## 基本操作 - -在我们之前使用MySQL时,我们需要先在数据库中创建一张表,并定义好表的每个字段内容,最后再通过`insert`语句向表中添加数据,而Redis并不具有MySQL那样的严格的表结构,Redis是一个键值数据库,因此,可以像Map一样的操作方式,通过键值对向Redis数据库中添加数据(操作起来类似于向一个HashMap中存放数据) - -在Redis下,数据库是由一个整数索引标识,而不是由一个数据库名称。 默认情况下,我们连接Redis数据库之后,会使用0号数据库,我们可以通过Redis配置文件中的参数来修改数据库总数,默认为16个。 - -我们可以通过`select`语句进行切换: - -```sql -select 序号; -``` - -### 数据操作 - -我们来看看,如何向Redis数据库中添加数据: - -```sql -set --- 一次性多个 -mset [ ]... -``` - -所有存入的数据默认会以**字符串**的形式保存,键值具有一定的命名规范,以方便我们可以快速定位我们的数据属于哪一个部分,比如用户的数据: - -```sql --- 使用冒号来进行板块分割,比如下面表示用户XXX的信息中的name属性,值为lbw -set user:info:用户ID:name lbw -``` - -我们可以通过键值获取存入的值: - -```sql -get -``` - -你以为Redis就仅仅只是存取个数据吗?它还支持数据的过期时间设定: - -```sql -set EX 秒 -set PX 毫秒 -``` - -当数据到达指定时间时,会被自动删除。我们也可以单独为其他的键值对设置过期时间: - -```sql -expire 秒 -``` - -通过下面的命令来查询某个键值对的过期时间还剩多少: - -```sql -ttl --- 毫秒显示 -pttl --- 转换为永久 -persist -``` - -那么当我们想直接删除这个数据时呢?直接使用: - -```sql -del ... -``` - -删除命令可以同时拼接多个键值一起删除。 - -当我们想要查看数据库中所有的键值时: - -```sql -keys * -``` - -也可以查询某个键是否存在: - -```sql -exists ... -``` - -还可以随机拿一个键: - -```sql -randomkey -``` - -我们可以将一个数据库中的内容移动到另一个数据库中: - -```sql -move 数据库序号 -``` - -修改一个键为另一个键: - -```sql -rename <新的名称> --- 下面这个会检查新的名称是否已经存在 -renamex <新的名称> -``` - -如果存放的数据是一个数字,我们还可以对其进行自增自减操作: - -```sql --- 等价于a = a + 1 -incr --- 等价于a = a + b -incrby b --- 等价于a = a - 1 -decr -``` - -最后就是查看值的数据类型: - -```sql -type -``` - -Redis数据库也支持多种数据类型,但是它更偏向于我们在Java中认识的那些数据类型。 - -## 数据类型介绍 - -一个键值对除了存储一个String类型的值以外,还支持多种常用的数据类型。 - -### Hash - -这种类型本质上就是一个HashMap,也就是嵌套了一个HashMap罢了,在Java中就像这样: - -```java -#Redis默认存String类似于这样: -Map hash = new HashMap<>(); -#Redis存Hash类型的数据类似于这样: -Map> hash = new HashMap<>(); -``` - -它比较适合存储类这样的数据,由于值本身又是一个Map,因此我们可以在此Map中放入类的各种属性和值,以实现一个Hash数据类型存储一个类的数据。 - -我们可以像这样来添加一个Hash类型的数据: - -```sql -hset [<字段> <值>]... -``` - -我们可以直接获取: - -```sql -hget <字段> --- 如果想要一次性获取所有的字段和值 -hgetall -``` - -同样的,我们也可以判断某个字段是否存在: - -```sql -hexists <字段> -``` - -删除Hash中的某个字段: - -```sql -hdel -``` - -我们发现,在操作一个Hash时,实际上就是我们普通操作命令前面添加一个`h`,这样就能以同样的方式去操作Hash里面存放的键值对了,这里就不一一列出所有的操作了。我们来看看几个比较特殊的。 - -我们现在想要知道Hash中一共存了多少个键值对: - -```sql -hlen -``` - -我们也可以一次性获取所有字段的值: - -```sql -hvals -``` - -唯一需要注意的是,Hash中只能存放字符串值,不允许出现嵌套的的情况。 - -### List - -我们接着来看List类型,实际上这个猜都知道,它就是一个列表,而列表中存放一系列的字符串,它支持随机访问,支持双端操作,就像我们使用Java中的LinkedList一样。 - -我们可以直接向一个已存在或是不存在的List中添加数据,如果不存在,会自动创建: - -```sql --- 向列表头部添加元素 -lpush ... --- 向列表尾部添加元素 -rpush ... --- 在指定元素前面/后面插入元素 -linsert before/after <指定元素> -``` - -同样的,获取元素也非常简单: - -```sql --- 根据下标获取元素 -lindex <下标> --- 获取并移除头部元素 -lpop --- 获取并移除尾部元素 -rpop --- 获取指定范围内的 -lrange start stop -``` - -注意下标可以使用负数来表示从后到前数的数字(Python:搁这儿抄呢是吧): - -```sql --- 获取列表a中的全部元素 -lrange a 0 -1 -``` - -没想到吧,push和pop还能连着用呢: - -```sql --- 从前一个数组的最后取一个数出来放到另一个数组的头部,并返回元素 -rpoplpush 当前数组 目标数组 -``` - -它还支持阻塞操作,类似于生产者和消费者,比如我们想要等待列表中有了数据后再进行pop操作: - -```sql --- 如果列表中没有元素,那么就等待,如果指定时间(秒)内被添加了数据,那么就执行pop操作,如果超时就作废,支持同时等待多个列表,只要其中一个列表有元素了,那么就能执行 -blpop ... timeout -``` - -### Set和SortedSet - -Set集合其实就像Java中的HashSet一样(我们在JavaSE中已经讲解过了,HashSet本质上就是利用了一个HashMap,但是Value都是固定对象,仅仅是Key不同)它不允许出现重复元素,不支持随机访问,但是能够利用Hash表提供极高的查找效率。 - -向Set中添加一个或多个值: - -```sql -sadd ... -``` - -查看Set集合中有多少个值: - -```sql -scard -``` - -判断集合中是否包含: - -```sql --- 是否包含指定值 -sismember --- 列出所有值 -smembers -``` - -集合之间的运算: - -```sql --- 集合之间的差集 -sdiff --- 集合之间的交集 -sinter --- 求并集 -sunion --- 将集合之间的差集存到目标集合中 -sdiffstore 目标 --- 同上 -sinterstore 目标 --- 同上 -sunionstore 目标 -``` - -移动指定值到另一个集合中: - -```sql -smove 目标 value -``` - -移除操作: - -```sql --- 随机移除一个幸运儿 -spop --- 移除指定 -srem ... -``` - -那么如果我们要求Set集合中的数据按照我们指定的顺序进行排列怎么办呢?这时就可以使用SortedSet,它支持我们为每个值设定一个分数,分数的大小决定了值的位置,所以它是有序的。 - -我们可以添加一个带分数的值: - -```sql -zadd [ ]... -``` - -同样的: - -```sql --- 查询有多少个值 -zcard --- 移除 -zrem ... --- 获取区间内的所有 -zrange start stop -``` - -由于所有的值都有一个分数,我们也可以根据分数段来获取: - -``` sql --- 通过分数段查看 -zrangebyscore start stop [withscores] [limit] --- 统计分数段内的数量 -zcount start stop --- 根据分数获取指定值的排名 -zrank -``` - -https://www.jianshu.com/p/32b9fe8c20e1 - -有关Bitmap、HyperLogLog和Geospatial等数据类型,这里暂时不做介绍,感兴趣可以自行了解。 - -*** - -## 持久化 - -我们知道,Redis数据库中的数据都是存放在内存中,虽然很高效,但是这样存在一个非常严重的问题,如果突然停电,那我们的数据不就全部丢失了吗?它不像硬盘上的数据,断电依然能够保存。 - -这个时候我们就需要持久化,我们需要将我们的数据备份到硬盘上,防止断电或是机器故障导致的数据丢失。 - -持久化的实现方式有两种方案:一种是直接保存当前**已经存储的数据**,相当于复制内存中的数据到硬盘上,需要恢复数据时直接读取即可;还有一种就是保存我们存放数据的**所有过程**,需要恢复数据时,只需要将整个过程完整地重演一遍就能保证与之前数据库中的内容一致。 - -### RDB - -RDB就是我们所说的第一种解决方案,那么如何将数据保存到本地呢?我们可以使用命令: - -```sql -save --- 注意上面这个命令是直接保存,会占用一定的时间,也可以单独开一个子进程后台执行保存 -bgsave -``` - -执行后,会在服务端目录下生成一个dump.rdb文件,而这个文件中就保存了内存中存放的数据,当服务器重启后,会自动加载里面的内容到对应数据库中。保存后我们可以关闭服务器: - -```sql -shutdown -``` - -重启后可以看到数据依然存在。 - -![点击查看图片来源](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fqqe2.com%2Fjava%2Fzb_users%2Fupload%2F2020%2F04%2F202004281588086055367603.png&refer=http%3A%2F%2Fqqe2.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1644843952&t=ec4cd6eb2c6d47a10aff5b9f264d2f16) - -虽然这种方式非常方便,但是由于会完整复制所有的数据,如果数据库中的数据量比较大,那么复制一次可能就需要花费大量的时间,所以我们可以每隔一段时间自动进行保存;还有就是,如果我们基本上都是在进行读操作,而没有进行写操作,实际上只需要偶尔保存一次即可,因为数据几乎没有怎么变化,可能两次保存的都是一样的数据。 - -我们可以在配置文件中设置自动保存,并设定在一段时间内写入多少数据时,执行一次保存操作: - -``` -save 300 10 # 300秒(5分钟)内有10个写入 -save 60 10000 # 60秒(1分钟)内有10000个写入 -``` - -配置的save使用的都是bgsave后台执行。 - -### AOF - -虽然RDB能够很好地解决数据持久化问题,但是它的缺点也很明显:每次都需要去完整地保存整个数据库中的数据,同时后台保存过程中也会产生额外的内存开销,最严重的是它并不是实时保存的,如果在自动保存触发之前服务器崩溃,那么依然会导致少量数据的丢失。 - -而AOF就是另一种方式,它会以日志的形式将我们每次执行的命令都进行保存,服务器重启时会将所有命令依次执行,通过这种重演的方式将数据恢复,这样就能很好解决实时性存储问题。 - -![rdb和aof区别](https://qqe2.com/java/zb_users/upload/2020/04/202004281588086068660716.png) - -但是,我们多久写一次日志呢?我们可以自己配置保存策略,有三种策略: - -* always:每次执行写操作都会保存一次 -* everysec:每秒保存一次(默认配置),这样就算丢失数据也只会丢一秒以内的数据 -* no:看系统心情保存 - -可以在配置文件中配置: - -```sql -# 注意得改成也是 -appendonly yes - -# appendfsync always -appendfsync everysec -# appendfsync no -``` - -重启服务器后,可以看到服务器目录下多了一个`appendonly.aof`文件,存储的就是我们执行的命令。 - - AOF的缺点也很明显,每次服务器启动都需要进行过程重演,相比RDB更加耗费时间,并且随着我们的操作变多,不断累计,可能到最后我们的aof文件会变得无比巨大,我们需要一个改进方案来优化这些问题。 - -Redis有一个AOF重写机制进行优化,比如我们执行了这样的语句: - -``` -lpush test 666 -lpush test 777 -lpush test 888 -``` - -实际上用一条语句也可以实现: - -``` -lpush test 666 777 888 -``` - -正是如此,只要我们能够保证最终的重演结果和原有语句的结果一致,无论语句如何修改都可以,所以我们可以通过这种方式将多条语句进行压缩。 - -我们可以输入命令来手动执行重写操作: - -```sql -bgrewriteaof -``` - -或是在配置文件中配置自动重写: - -``` -# 百分比计算,这里不多介绍 -auto-aof-rewrite-percentage 100 -# 当达到这个大小时,触发自动重写 -auto-aof-rewrite-min-size 64mb -``` - -至此,我们就完成了两种持久化方案的介绍,最后我们再来进行一下总结: - -* AOF: - * 优点:存储速度快、消耗资源少、支持实时存储 - * 缺点:加载速度慢、数据体积大 -* RDB: - * 优点:加载速度快、数据体积小 - * 缺点:存储速度慢大量消耗资源、会发生数据丢失 - -*** - -## 事务和锁机制 - -和MySQL一样,在Redis中也有事务机制,当我们需要保证多条命令一次性完整执行而中途不受到其他命令干扰时,就可以使用事务机制。 - -我们可以使用命令来直接开启事务: - -```sql -multi -``` - -当我们输入完所有要执行的命令时,可以使用命令来立即执行事务: - -```sql -exec -``` - -我们也可以中途取消事务: - -```sql -discard -``` - -实际上整个事务是创建了一个命令队列,它不像MySQL那种在事务中也能单独得到结果,而是我们提前将所有的命令装在队列中,但是并不会执行,而是等我们提交事务的时候再统一执行。 - -### 锁 - -又提到锁了,实际上这个概念对我们来说已经不算是陌生了。实际上在Redis中也会出现多个命令同时竞争同一个数据的情况,比如现在有两条命令同时执行,他们都要去修改a的值,那么这个时候就只能动用锁机制来保证同一时间只能有一个命令操作。 - -虽然Redis中也有锁机制,但是它是一种乐观锁,不同于MySQL,我们在MySQL中认识的锁是悲观锁,那么什么是乐观锁什么是悲观锁呢? - -* 悲观锁:时刻认为别人会来抢占资源,禁止一切外来访问,直到释放锁,具有强烈的排他性质。 -* 乐观锁:并不认为会有人来抢占资源,所以会直接对数据进行操作,在操作时再去验证是否有其他人抢占资源。 - -Redis中可以使用watch来监视一个目标,如果执行事务之前被监视目标发生了修改,则取消本次事务: - -```sql -watch -``` - -我们可以开两个客户端进行测试。 - -取消监视可以使用: - -```sql -unwatch -``` - -至此,Redis的基础内容就讲解完毕了,在之后的SpringCloud阶段,我们还会去讲解集群相关的知识,包括主从复制、哨兵模式等。 - -*** - -## 使用Java与Redis交互 - -既然了解了如何通过命令窗口操作Redis数据库,那么我们如何使用Java来操作呢? - -这里我们需要使用到Jedis框架,它能够实现Java与Redis数据库的交互,依赖: - -```xml - - - redis.clients - jedis - 4.0.0 - - -``` - -### 基本操作 - -我们来看看如何连接Redis数据库,非常简单,只需要创建一个对象即可: - -```java -public static void main(String[] args) { - //创建Jedis对象 - Jedis jedis = new Jedis("localhost", 6379); - - //使用之后关闭连接 - jedis.close(); -} -``` - -通过Jedis对象,我们就可以直接调用命令的同名方法来执行Redis命令了,比如: - -```java -public static void main(String[] args) { - //直接使用try-with-resouse,省去close - try(Jedis jedis = new Jedis("192.168.10.3", 6379)){ - jedis.set("test", "lbwnb"); //等同于 set test lbwnb 命令 - System.out.println(jedis.get("test")); //等同于 get test 命令 - } -} -``` - -Hash类型的数据也是这样: - -```java -public static void main(String[] args) { - try(Jedis jedis = new Jedis("192.168.10.3", 6379)){ - jedis.hset("hhh", "name", "sxc"); //等同于 hset hhh name sxc - jedis.hset("hhh", "sex", "19"); //等同于 hset hhh age 19 - jedis.hgetAll("hhh").forEach((k, v) -> System.out.println(k+": "+v)); - } -} -``` - -我们接着来看看列表操作: - -```java -public static void main(String[] args) { - try(Jedis jedis = new Jedis("192.168.10.3", 6379)){ - jedis.lpush("mylist", "111", "222", "333"); //等同于 lpush mylist 111 222 333 命令 - jedis.lrange("mylist", 0, -1) - .forEach(System.out::println); //等同于 lrange mylist 0 -1 - } -} -``` - -实际上我们只需要按照对应的操作去调用同名方法即可,所有的类型封装Jedis已经帮助我们完成了。 - -### SpringBoot整合Redis - -我们接着来看如何在SpringBoot项目中整合Redis操作框架,只需要一个starter即可,但是它底层没有用Jedis,而是Lettuce: - -```xml - - org.springframework.boot - spring-boot-starter-data-redis - -``` - -starter提供的默认配置会去连接本地的Redis服务器,并使用0号数据库,当然你也可以手动进行修改: - -```yaml -spring: - redis: - #Redis服务器地址 - host: 192.168.10.3 - #端口 - port: 6379 - #使用几号数据库 - database: 0 -``` - -starter已经给我们提供了两个默认的模板类: - -```java -@Configuration( - proxyBeanMethods = false -) -@ConditionalOnClass({RedisOperations.class}) -@EnableConfigurationProperties({RedisProperties.class}) -@Import({LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class}) -public class RedisAutoConfiguration { - public RedisAutoConfiguration() { - } - - @Bean - @ConditionalOnMissingBean( - name = {"redisTemplate"} - ) - @ConditionalOnSingleCandidate(RedisConnectionFactory.class) - public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { - RedisTemplate template = new RedisTemplate(); - template.setConnectionFactory(redisConnectionFactory); - return template; - } - - @Bean - @ConditionalOnMissingBean - @ConditionalOnSingleCandidate(RedisConnectionFactory.class) - public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) { - return new StringRedisTemplate(redisConnectionFactory); - } -} -``` - -那么如何去使用这两个模板类呢?我们可以直接注入`StringRedisTemplate`来使用模板: - -```java -@SpringBootTest -class SpringBootTestApplicationTests { - - @Autowired - StringRedisTemplate template; - - @Test - void contextLoads() { - ValueOperations operations = template.opsForValue(); - operations.set("c", "xxxxx"); //设置值 - System.out.println(operations.get("c")); //获取值 - - template.delete("c"); //删除键 - System.out.println(template.hasKey("c")); //判断是否包含键 - } - -} -``` - -实际上所有的值的操作都被封装到了`ValueOperations`对象中,而普通的键操作直接通过模板对象就可以使用了,大致使用方式其实和Jedis一致。 - -我们接着来看看事务操作,由于Spring没有专门的Redis事务管理器,所以只能借用JDBC提供的,只不过无所谓,正常情况下反正我们也要用到这玩意: - -```xml - - org.springframework.boot - spring-boot-starter-jdbc - - - mysql - mysql-connector-java - -``` - -```java -@Service -public class RedisService { - - @Resource - StringRedisTemplate template; - - @PostConstruct - public void init(){ - template.setEnableTransactionSupport(true); //需要开启事务 - } - - @Transactional //需要添加此注解 - public void test(){ - template.multi(); - template.opsForValue().set("d", "xxxxx"); - template.exec(); - } -} -``` - -我们还可以为RedisTemplate对象配置一个Serializer来实现对象的JSON存储: - -```java -@Test -void contextLoad2() { - //注意Student需要实现序列化接口才能存入Redis - template.opsForValue().set("student", new Student()); - System.out.println(template.opsForValue().get("student")); -} -``` - -*** - -## 使用Redis做缓存 - -我们可以轻松地使用Redis来实现一些框架的缓存和其他存储。 - -### Mybatis二级缓存 - -还记得我们在学习Mybatis讲解的缓存机制吗,我们当时介绍了二级缓存,它是Mapper级别的缓存,能够作用与所有会话。但是当时我们提出了一个问题,由于Mybatis的默认二级缓存只能是单机的,如果存在多台服务器访问同一个数据库,实际上二级缓存只会在各自的服务器上生效,但是我们希望的是多台服务器都能使用同一个二级缓存,这样就不会造成过多的资源浪费。 - -![img](https://img-blog.csdnimg.cn/img_convert/5afd7713f9a97615dc3a0b1d3bc7db27.png) - -我们可以将Redis作为Mybatis的二级缓存,这样就能实现多台服务器使用同一个二级缓存,因为它们只需要连接同一个Redis服务器即可,所有的缓存数据全部存储在Redis服务器上。我们需要手动实现Mybatis提供的Cache接口,这里我们简单编写一下: - -```java -//实现Mybatis的Cache接口 -public class RedisMybatisCache implements Cache { - - private final String id; - private static RedisTemplate template; - - //注意构造方法必须带一个String类型的参数接收id - public RedisMybatisCache(String id){ - this.id = id; - } - - //初始化时通过配置类将RedisTemplate给过来 - public static void setTemplate(RedisTemplate template) { - RedisMybatisCache.template = template; - } - - @Override - public String getId() { - return id; - } - - @Override - public void putObject(Object o, Object o1) { - //这里直接向Redis数据库中丢数据即可,o就是Key,o1就是Value,60秒为过期时间 - template.opsForValue().set(o, o1, 60, TimeUnit.SECONDS); - } - - @Override - public Object getObject(Object o) { - //这里根据Key直接从Redis数据库中获取值即可 - return template.opsForValue().get(o); - } - - @Override - public Object removeObject(Object o) { - //根据Key删除 - return template.delete(o); - } - - @Override - public void clear() { - //由于template中没封装清除操作,只能通过connection来执行 - template.execute((RedisCallback) connection -> { - //通过connection对象执行清空操作 - connection.flushDb(); - return null; - }); - } - - @Override - public int getSize() { - //这里也是使用connection对象来获取当前的Key数量 - return template.execute(RedisServerCommands::dbSize).intValue(); - } -} -``` - -缓存类编写完成后,我们接着来编写配置类: - -```java -@Configuration -public class MainConfiguration { - @Resource - RedisTemplate template; - - @PostConstruct - public void init(){ - //把RedisTemplate给到RedisMybatisCache - RedisMybatisCache.setTemplate(template); - } -} -``` - -最后我们在Mapper上启用此缓存即可: - -```java -//只需要修改缓存实现类implementation为我们的RedisMybatisCache即可 -@CacheNamespace(implementation = RedisMybatisCache.class) -@Mapper -public interface MainMapper { - - @Select("select name from student where sid = 1") - String getSid(); -} -``` - -最后我们提供一个测试用例来查看当前的二级缓存是否生效: - -```java -@SpringBootTest -class SpringBootTestApplicationTests { - - - @Resource - MainMapper mapper; - - @Test - void contextLoads() { - System.out.println(mapper.getSid()); - System.out.println(mapper.getSid()); - System.out.println(mapper.getSid()); - } - -} -``` - -手动使用客户端查看Redis数据库,可以看到已经有一条Mybatis生成的缓存数据了。 - -### Token持久化存储 - -我们之前使用SpringSecurity时,remember-me的Token是支持持久化存储的,而我们当时是存储在数据库中,那么Token信息能否存储在缓存中呢,当然也是可以的,我们可以手动实现一个: - -```java -//实现PersistentTokenRepository接口 -@Component -public class RedisTokenRepository implements PersistentTokenRepository { - //Key名称前缀,用于区分 - private final static String REMEMBER_ME_KEY = "spring:security:rememberMe:"; - @Resource - RedisTemplate template; - - @Override - public void createNewToken(PersistentRememberMeToken token) { - //这里要放两个,一个存seriesId->Token,一个存username->seriesId,因为删除时是通过username删除 - template.opsForValue().set(REMEMBER_ME_KEY+"username:"+token.getUsername(), token.getSeries()); - template.expire(REMEMBER_ME_KEY+"username:"+token.getUsername(), 1, TimeUnit.DAYS); - this.setToken(token); - } - - //先获取,然后修改创建一个新的,再放入 - @Override - public void updateToken(String series, String tokenValue, Date lastUsed) { - PersistentRememberMeToken token = this.getToken(series); - if(token != null) - this.setToken(new PersistentRememberMeToken(token.getUsername(), series, tokenValue, lastUsed)); - } - - @Override - public PersistentRememberMeToken getTokenForSeries(String seriesId) { - return this.getToken(seriesId); - } - - //通过username找seriesId直接删除这两个 - @Override - public void removeUserTokens(String username) { - String series = (String) template.opsForValue().get(REMEMBER_ME_KEY+"username:"+username); - template.delete(REMEMBER_ME_KEY+series); - template.delete(REMEMBER_ME_KEY+"username:"+username); - } - - - //由于PersistentRememberMeToken没实现序列化接口,这里只能用Hash来存储了,所以单独编写一个set和get操作 - private PersistentRememberMeToken getToken(String series){ - Map map = template.opsForHash().entries(REMEMBER_ME_KEY+series); - if(map.isEmpty()) return null; - return new PersistentRememberMeToken( - (String) map.get("username"), - (String) map.get("series"), - (String) map.get("tokenValue"), - new Date(Long.parseLong((String) map.get("date")))); - } - - private void setToken(PersistentRememberMeToken token){ - Map map = new HashMap<>(); - map.put("username", token.getUsername()); - map.put("series", token.getSeries()); - map.put("tokenValue", token.getTokenValue()); - map.put("date", ""+token.getDate().getTime()); - template.opsForHash().putAll(REMEMBER_ME_KEY+token.getSeries(), map); - template.expire(REMEMBER_ME_KEY+token.getSeries(), 1, TimeUnit.DAYS); - } -} -``` - -接着把验证Service实现了: - -```java -@Service -public class AuthService implements UserDetailsService { - - @Resource - UserMapper mapper; - - @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - Account account = mapper.getAccountByUsername(username); - if(account == null) throw new UsernameNotFoundException(""); - return User - .withUsername(username) - .password(account.getPassword()) - .roles(account.getRole()) - .build(); - } -} -``` - -Mapper也安排上: - -```java -@Data -public class Account implements Serializable { - int id; - String username; - String password; - String role; -} -``` - -```java -@CacheNamespace(implementation = MybatisRedisCache.class) -@Mapper -public interface UserMapper { - - @Select("select * from users where username = #{username}") - Account getAccountByUsername(String username); -} -``` - -最后配置文件配一波: - -```java -@Override -protected void configure(HttpSecurity http) throws Exception { - http - .authorizeRequests() - .anyRequest().authenticated() - .and() - .formLogin() - .and() - .rememberMe() - .tokenRepository(repository); -} - -@Override -protected void configure(AuthenticationManagerBuilder auth) throws Exception { - auth - .userDetailsService(service) - .passwordEncoder(new BCryptPasswordEncoder()); -} -``` - -OK,启动服务器验证一下吧。 - -*** - -## 三大缓存问题 - -**注意:**这部分内容作为选学内容。 - -虽然我们可以利用缓存来大幅度提升我们程序的数据获取效率,但是使用缓存也存在着一些潜在的问题。 - -### 缓存穿透 - -![img](https://mydlq-club.oss-cn-beijing.aliyuncs.com/images/springboot-cache-redis-1004.png?x-oss-process=style/shuiyin) - -当我们去查询一个一定不存在的数据,比如Mybatis在缓存是未命中的情况下需要从数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,造成缓存穿透。 - -这显然是很浪费资源的,我们希望的是,如果这个数据不存在,为什么缓存这一层不直接返回空呢,这时就不必再去查数据库了,但是也有一个问题,缓存不去查数据库怎么知道数据库里面到底有没有这个数据呢? - -这时我们就可以使用布隆过滤器来进行判断。什么是布隆过滤器?(当然不是打辅助的那个布隆,只不过也挺像,辅助布隆也是挡子弹的) - -![点击查看图片来源](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimage.bubuko.com%2Finfo%2F201903%2F20190321142642446276.png&refer=http%3A%2F%2Fimage.bubuko.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1644902390&t=4f0440b0357965ead1fa34fb27513927) - -使用布隆过滤器,能够告诉你某样东西一定不存在或是某样东西可能存在。 - -布隆过滤器本质是一个存放二进制位的bit数组,如果我们要添加一个值到布隆过滤器中,我们需要使用N个不同的哈希函数来生成N个哈希值,并对每个生成的哈希值指向的bit位置1,如上图所示,一共添加了三个值abc。 - -接着我们给一个d,那么这时就可以进行判断,如果说d计算的N个哈希值的位置上都是1,那么就说明d可能存在;这时候又来了个e,计算后我们发现有一个位置上的值是0,这时就可以直接断定e一定不存在。 - -### 缓存击穿 - -![img](https://mydlq-club.oss-cn-beijing.aliyuncs.com/images/springboot-cache-redis-1005.png?x-oss-process=style/shuiyin) - -某个 Key 属于热点数据,访问非常频繁,同一时间很多人都在访问,在这个Key失效的瞬间,大量的请求到来,这时发现缓存中没有数据,就全都直接请求数据库,相当于击穿了缓存屏障,直接攻击整个系统核心。 - -这种情况下,最好的解决办法就是不让Key那么快过期,如果一个Key处于高频访问,那么可以适当地延长过期时间。 - -### 缓存雪崩 - -![img](https://mydlq-club.oss-cn-beijing.aliyuncs.com/images/springboot-cache-redis-1006.png?x-oss-process=style/shuiyin) - -当你的Redis服务器炸了或是大量的Key在同一时间过期,这时相当于缓存直接GG了,那么如果这时又有很多的请求来访问不同的数据,同一时间内缓存服务器就得向数据库大量发起请求来重新建立缓存,很容易把数据库也搞GG。 - -解决这种问题最好的办法就是设置高可用,也就是搭建Redis集群,当然也可以采取一些服务熔断降级机制,这些内容我们会在SpringCloud阶段再进行探讨。 diff --git a/青空笔记/SpringBoot笔记/SpringBoot笔记(二).md b/青空笔记/SpringBoot笔记/SpringBoot笔记(二).md deleted file mode 100644 index 63341d7..0000000 --- a/青空笔记/SpringBoot笔记/SpringBoot笔记(二).md +++ /dev/null @@ -1,403 +0,0 @@ -# Git版本控制 - -**注意:**开始学习之前,确保自己的网络可以畅通的连接Github:https://github.com,这个是一个国外网站,连起来特别卡,至于用什么方式实现流畅访问,懂的都懂。 - -其实版本控制在我们的生活中无处不在,比如你的期末或是毕业答辩论文,由于你写得不规范或是老师不满意,你的老师可能会让你改了又改,于是就会出现下面这种情况: - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2F5b0988e595225.cdn.sohucs.com%2Fimages%2F20200417%2F1e63ac0f4d8442cb8c9ab1cb73f510c4.jpeg&refer=http%3A%2F%2F5b0988e595225.cdn.sohucs.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1644370473&t=fa8742db0b4f8db635ec003e37bca76c) - -我们手里的论文可能会经过多次版本迭代,最终我们会选取一个最好的版本作为最终提交的论文。使用版本控制不仅仅是为了去记录版本迭代历史,更是为了能够随时回退到之前的版本,实现时间回溯。同时,可能我们的论文是多个人一同完成,那么多个人如何去实现同步,如何保证每个人提交的更改都能够正常汇总,如何解决冲突,这些问题都需要一个优秀的版本控制系统来解决。 - -## 走进Git - -我们开发的项目,也需要一个合适的版本控制系统来协助我们更好地管理版本迭代,而Git正是因此而诞生的(有关Git的历史,这里就不多做阐述了,感兴趣的小伙伴可以自行了解,是一位顶级大佬在一怒之下只花了2周时间用C语言开发的,之后的章节还会遇到他) - -首先我们来了解一下Git是如何工作的: - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg2020.cnblogs.com%2Fblog%2F932856%2F202004%2F932856-20200423143251346-796113044.jpg&refer=http%3A%2F%2Fimg2020.cnblogs.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1644374894&t=7c2044128f7851ecd92de3c01f0187ca) - -可以看到,它大致分为4个板块: - -* 工作目录:存放我们正在写的代码(当我们新版本开发完成之后,就可以进行新版本的提交) -* 暂存区:暂时保存待提交的内容(新版本提交后会存放到本地仓库) -* 本地仓库:位于我们电脑上的一个版本控制仓库(存放的就是当前项目各个版本代码的增删信息) -* 远程仓库:位于服务器上的版本控制仓库(服务器上的版本信息可以由本地仓库推送上去,也可以从服务器抓取到本地仓库) - -它是一个分布式的控制系统,因此一般情况下我们每个人的电脑上都有一个本地仓库,由大家共同向远程仓库去推送版本迭代信息。 - -通过这一系列操作,我们就可以实现每开发完一个版本或是一个功能,就提交一次新版本,这样,我们就可以很好地控制项目的版本迭代,想回退到之前的版本随时都可以回退,想查看新版本添加或是删除了什么代码,随时都可以查看。 - -## 安装Git - -首先请前往Git官网去下载最新的安装包:https://git-scm.com/download/win - -这手把手演示一下如何安装Git环境。 - -安装完成后,需要设定用户名和邮箱来区分不同的用户: - -```shell -git config --global user.name "Your Name" -git config --global user.email "email@example.com" -``` - -## 基本命令介绍 - -### 创建本地仓库 - -我们可以将任意一个文件夹作为一个本地仓库,输入: - -```shell -git init -``` - -输入后,会自动生成一个`.git`目录,注意这个目录是一个隐藏目录,而当前目录就是我们的工作目录。 - -创建成功后,我们可以查看一下当前的一个状态,输入: - -```shell -git status -``` - -如果已经成功配置为Git本地仓库,那么输入后可以看到: - -```shell -On branch master - -No commits yet -``` - -这表示我们还没有向仓库中提交任何内容,也就是一个空的状态。 - -### 添加和提交 - -接着我们来看看,如何使用git来管理我们文档的版本,我们创建一个文本文档,随便写入一点内容,接着输入: - -```shell -git status -``` - -我们会得到如下提示: - -```shell -Untracked files: - (use "git add ..." to include in what will be committed) - hello.txt - -nothing added to commit but untracked files present (use "git add" to track) -``` - -其中Untracked files是未追踪文件的意思,也就是说,如果一个文件处于未追踪状态,那么git不会记录它的变化,始终将其当做一个新创建的文件,这里我们将其添加到暂存区,那么它会自动变为被追踪状态: - -```shell -git add hello.txt #也可以 add . 一次性添加目录下所有的 -``` - -再次查看当前状态: - -```sh -Changes to be committed: - (use "git rm --cached ..." to unstage) - new file: hello.txt -``` - -现在文件名称的颜色变成了绿色,并且是处于Changes to be committed下面,因此,我们的hello.txt现在已经被添加到暂存区了。 - -接着我们来尝试将其提交到Git本地仓库中,注意需要输入提交的描述以便后续查看,比如你这次提交修改了或是新增了哪些内容: - -```sh -git commit -m 'Hello World' -``` - -接着我们可以查看我们的提交记录: - -```sh -git log -git log --graph -``` - -我们还可以查看最近一次变更的详细内容: - -```sh -git show [也可以加上commit ID查看指定的提交记录] -``` - -再次查看当前状态,已经是清空状态了: - -```sh -On branch master -nothing to commit, working tree clean -``` - -接着我们可以尝试修改一下我们的文本文档,由于当前文件已经是被追踪状态,那么git会去跟踪它的变化,如果说文件发生了修改,那么我们再次查看状态会得到下面的结果: - -```sh -Changes not staged for commit: - (use "git add ..." to update what will be committed) - (use "git restore ..." to discard changes in working directory) - modified: hello.txt -``` - -也就是说现在此文件是处于已修改状态,我们如果修改好了,就可以提交我们的新版本到本地仓库中: - -```sh -git add . -git commit -m 'Modify Text' -``` - -接着我们来查询一下提交记录,可以看到一共有两次提交记录。 - -我们可以创建一个`.gitignore`文件来确定一个文件忽略列表,如果忽略列表中的文件存在且不是被追踪状态,那么git不会对其进行任何检查: - -```yaml -# 这样就会匹配所有以txt结尾的文件 -*.txt -# 虽然上面排除了所有txt结尾的文件,但是这个不排除 -!666.txt -# 也可以直接指定一个文件夹,文件夹下的所有文件将全部忽略 -test/ -# 目录中所有以txt结尾的文件,但不包括子目录 -xxx/*.txt -# 目录中所有以txt结尾的文件,包括子目录 -xxx/**/*.txt -``` - -创建后,我们来看看是否还会检测到我们忽略的文件。 - -### 回滚 - -当我们想要回退到过去的版本时,就可以执行回滚操作,执行后,可以将工作空间的内容恢复到指定提交的状态: - -```sh -git reset --hard commitID -``` - -执行后,会直接重置为那个时候的状态。再次查看提交日志,我们发现之后的日志全部消失了。 - -那么要是现在我又想回去呢?我们可以通过查看所有分支的所有操作记录: - -```sh -git reflog -``` - -这样就能找到之前的commitID,再次重置即可。 - -## 分支 - -分支就像我们树上的一个树枝一样,它们可能一开始的时候是同一根树枝,但是长着长着就开始分道扬镳了,这就是分支。我们的代码也是这样,可能一开始写基础功能的时候使用的是单个分支,但是某一天我们希望基于这些基础的功能,把我们的项目做成两个不同方向的项目,比如一个方向做Web网站,另一个方向做游戏服务端。 - -因此,我们可以在一个主干上分出N个分支,分别对多个分支的代码进行维护。 - -### 创建分支 - -我们可以通过以下命令来查看当前仓库中存在的分支: - -```sh -git branch -``` - -我们发现,默认情况下是有一个master分支的,并且我们使用的也是master分支,一般情况下master分支都是正式版本的更新,而其他分支一般是开发中才频繁更新的。我们接着来基于当前分支创建一个新的分支: - -```sh -git branch test -# 对应的删除分支是 -git branch -d yyds -``` - -现在我们修改一下文件,提交,再查看一下提交日志: - -```sh -git commit -a -m 'branch master commit' -``` - -通过添加-a来自动将未放入暂存区的已修改文件放入暂存区并执行提交操作。查看日志,我们发现现在我们的提交只生效于master分支,而新创建的分支并没有发生修改。 - -我们将分支切换到另一个分支: - -```sh -git checkout test -``` - -我们会发现,文件变成了此分支创建的时的状态,也就是说,在不同分支下我们的文件内容是相互隔离的。 - -我们现在再来提交一次变更,会发现它只生效在yyds分支上。我们可以看看当前的分支状态: - -```sh -git log --all --graph -``` - -### 合并分支 - -我们也可以将两个分支更新的内容最终合并到同一个分支上,我们先切换回主分支: - -```sh -git checkout master -``` - -接着使用分支合并命令: - -```sh -git merge test -``` - -会得到如下提示: - -``` -Auto-merging hello.txt -CONFLICT (content): Merge conflict in hello.txt -Automatic merge failed; fix conflicts and then commit the result. -``` - -在合并过程中产生了冲突,因为两个分支都对hello.txt文件进行了修改,那么现在要合并在一起,到底保留谁的hello文件呢? - -我们可以查看一下是哪里发生了冲突: - -```sh -git diff -``` - -因此,现在我们将master分支的版本回退到修改hello.txt之前或是直接修改为最新版本的内容,这样就不会有冲突了,接着再执行一次合并操作,现在两个分支成功合并为同一个分支。 - -### 变基分支 - -除了直接合并分支以外,我们还可以进行变基操作,它跟合并不同,合并是分支回到主干的过程,而变基是直接修改分支开始的位置,比如我们希望将yyds变基到master上,那么yyds会将分支起点移动到master最后一次提交位置: - -```sh -git rebase master -``` - -变基后,yyds分支相当于同步了此前master分支的全部提交。 - -### 优选 - -我们还可以选择其将他分支上的提交作用于当前分支上,这种操作称为cherrypick: - -```sh -git cherry-pick :单独合并一个提交 -``` - -这里我们在master分支上创建一个新的文件,提交此次更新,接着通过cherry-pick的方式将此次更新作用于test分支上。 - -*** - -## 使用IDEA版本控制 - -虽然前面我们基本讲解了git的命令行使用方法,但是没有一个图形化界面,始终会感觉到很抽象,所以这里我们使用IDEA来演示,IDEA内部集成了git模块,它可以让我们的git版本管理图形化显示,当然除了IDEA也有一些独立的软件比如:SourceTree(挺好用) - -打开IDEA后,找到版本控模块,我们直接点击创建本地仓库,它会自动将当前项目的根目录作为我们的本地仓库,而我们编写的所有代码和项目目录下其他的文件都可以进行版本控制。 - -我们发现所有项目中正在编写的类文件全部变红了,也就是处于未追踪状态,接着我们进行第一次初始化提交,提交之后我们可以在下方看到所有的本地仓库提交记录。 - -接着我们来整合一下Web环境,创建新的类之后,IDEA会提示我们是否将文件添加到Git,也就是是否放入暂存区并开启追踪,我们可以直接对比两次代码的相同和不同之处。 - -接着我们来演示一下分支创建和分支管理。 - -*** - -## 远程仓库 - -远程仓库实际上就是位于服务器上的仓库,它能在远端保存我们的版本历史,并且可以实现多人同时合作编写项目,每个人都能够同步他人的版本,能够看到他人的版本提交,相当于将我们的代码放在服务器上进行托管。 - -远程仓库有公有和私有的,公有的远程仓库有GitHub、码云、Coding等,他们都是对外开放的,我们注册账号之后就可以使用远程仓库进行版本控制,其中最大的就是GitHub,但是它服务器在国外,我们国内连接可能会有一点卡。私有的一般是GitLab这种自主搭建的远程仓库私服,在公司中比较常用,它只对公司内部开放,不对外开放。 - -这里我们以GitHub做讲解,官网:https://github.com,首先完成用户注册。 - -### 远程账户认证和推送 - -接着我们就可以创建一个自定义的远程仓库了。 - -创建仓库后,我们可以通过推送来将本地仓库中的内容推送到远程仓库。 - -```sh -git remote add 名称 远程仓库地址 -git push 远程仓库名称 本地分支名称[:远端分支名称] -``` - -注意`push`后面两个参数,一个是远端名称,还有一个就是本地分支名称,但是如果本地分支名称和远端分支名称一致,那么不用指定远端分支名称,但是如果我们希望推送的分支在远端没有同名的,那么需要额外指定。推送前需要登陆账户,GitHub现在不允许使用用户名密码验证,只允许使用个人AccessToken来验证身份,所以我们需要先去生成一个Token才可以。 - -推送后,我们发现远程仓库中的内容已经与我们本地仓库中的内容保持一致了,注意,远程仓库也可以有很多个分支。 - -但是这样比较麻烦,我们每次都需要去输入用户名和密码,有没有一劳永逸的方法呢?当然,我们也可以使用SSH来实现一次性校验,我们可以在本地生成一个rsa公钥: - -```sh -ssh-keygen -t rsa -cat ~/.ssh/github.pub -``` - -接着我们需要在GitHub上上传我们的公钥,当我们再次去访问GitHub时,会自动验证,就无需进行登录了,之后在Linux部分我们会详细讲解SSH的原理。 - -接着我们修改一下工作区的内容,提交到本地仓库后,再推送到远程仓库,提交的过程中我们注意观察提交记录: - -```sh -git commit -a -m 'Modify files' -git log --all --oneline --graph -git push origin master -git log --all --oneline --graph -``` - -我们可以将远端和本地的分支进行绑定,绑定后就不需要指定分支名称了: - -```sh -git push --set-upstream origin master:master -git push origin -``` - -在一个本地仓库对应一个远程仓库的情况下,远程仓库基本上就是纯粹的代码托管了(云盘那种感觉,就纯粹是存你代码的) - -### 克隆项目 - -如果我们已经存在一个远程仓库的情况下,我们需要在远程仓库的代码上继续编写代码,这个时候怎么办呢? - -我们可以使用克隆操作来将远端仓库的内容全部复制到本地: - -```sh -git clone 远程仓库地址 -``` - -这样本地就能够直接与远程保持同步。 - -### 抓取、拉取和冲突解决 - -我们接着来看,如果这个时候,出现多个本地仓库对应一个远程仓库的情况下,比如一个团队里面,N个人都在使用同一个远程仓库,但是他们各自只负责编写和推送自己业务部分的代码,也就是我们常说的协同工作,那么这个时候,我们就需要协调。 - -比如程序员A完成了他的模块,那么他就可以提交代码并推送到远程仓库,这时程序员B也要开始写代码了,由于远程仓库有其他程序员的提交记录,因此程序员B的本地仓库和远程仓库不一致,这时就需要有先进行pull操作,获取远程仓库中最新的提交: - -```sh -git fetch 远程仓库 #抓取:只获取但不合并远端分支,后面需要我们手动合并才能提交 -git pull 远程仓库 #拉取:获取+合并 -``` - -在程序员B拉取了最新的版本后,再编写自己的代码然后提交就可以实现多人合作编写项目了,并且在拉取过程中就能将别人提交的内容同步到本地,开发效率大大提升。 - -如果工作中存在不协调的地方,比如现在我们本地有两个仓库,一个仓库去修改hello.txt并直接提交,另一个仓库也修改hello.txt并直接提交,会得到如下错误: - -``` -To https://github.com/xx/xxx.git - ! [rejected] master -> master (fetch first) -error: failed to push some refs to 'https://github.com/xx/xxx.git' -hint: Updates were rejected because the remote contains work that you do -hint: not have locally. This is usually caused by another repository pushing -hint: to the same ref. You may want to first integrate the remote changes -hint: (e.g., 'git pull ...') before pushing again. -hint: See the 'Note about fast-forwards' in 'git push --help' for details. -``` - -一旦一个本地仓库推送了代码,那么另一个本地仓库的推送会被拒绝,原因是当前文件已经被其他的推送给修改了,我们这边相当于是另一个版本,和之前两个分支合并一样,产生了冲突,因此我们只能去解决冲突问题。 - -如果远程仓库中的提交和本地仓库中的提交没有去编写同一个文件,那么就可以直接拉取: - -```sh -git pull 远程仓库 -``` - -拉取后会自动进行合并,合并完成之后我们再提交即可。 - -但是如果两次提交都修改了同一个文件,那么就会遇到和多分支合并一样的情况,在合并时会产生冲突,这时就需要我们自己去解决冲突了。 - -我们可以在IDEA中演示一下,实际开发场景下可能会遇到的问题。 - -*** - -至此,Git版本控制就讲解到这里,下一章我们会继续认识一个全新的数据库:Redis。 - -![点击查看源网页](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fpic4.zhimg.com%2Fv2-054d8ff6135b3638aca543eff7424f98_1200x500.jpg&refer=http%3A%2F%2Fpic4.zhimg.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1644402012&t=79c30b8002d088850e33bd90492419b2) - diff --git a/青空笔记/SpringBoot笔记/SpringBoot笔记(五).md b/青空笔记/SpringBoot笔记/SpringBoot笔记(五).md deleted file mode 100644 index 1a6b8a6..0000000 --- a/青空笔记/SpringBoot笔记/SpringBoot笔记(五).md +++ /dev/null @@ -1,1130 +0,0 @@ -![点击查看图片来源](https://gimg2.baidu.com/image_search/src=http%3A%2F%2F5b0988e595225.cdn.sohucs.com%2Fimages%2F20171116%2F4f98ec55839b434cba335319b5ebf963.jpeg&refer=http%3A%2F%2F5b0988e595225.cdn.sohucs.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1645350734&t=099b0e4fb640617498cc40939e7e2070) - -# Linux操作系统与项目部署 - -**注意:**本版块会涉及到`操作系统`相关知识。 - -现在,几乎所有智能设备都有一个自己的操作系统,比如我们的家用个人电脑,基本都是预装Windows操作系统,我们的手机也有Android和iOS操作系统,还有程序员比较青睐的MacBook,预装MacOS操作系统,甚至连Macbook的Touchbar都有一个自己的小型操作系统。 - -> 操作系统是管理计算机硬件与软件资源的计算机程序,操作系统可以对计算机系统的各项资源板块开展调度工作,运用计算机操作系统可以减少人工资源分配的工作强度。 - -在我们的电脑没有操作系统的情况下,它就是一堆电子元器件组合而成的机器,就像我们有了一具完整的身体,但是现在缺少的是一个大脑,来控制我们的身体做出各种动作和行为,而安装了操作系统,就像为电脑注入了灵魂,操作系统会帮助我们对所有的硬件进行调度和管理。 - -比如我们现在最常用的Windows操作系统,我们可以在系统中做各种各样的事情,包括游戏、看片、学习、编程等,而所有的程序正是基于操作系统之上运行的,操作系统帮助我们与底层硬件进行交互,而在程序中我们只需要告诉操作系统我们需要做什么就可以了,操作系统知道该如何使用和调度底层的硬件,来完成我们程序中指定的任务。 - -(如果你在自己电脑上安装过Windows操作系统,甚至自己打过驱动程序,或是使用安装过Linux任意发行版本,那么本章学习起来会比较轻松) - -## 发展简史 - -这是整个SpringBoot阶段的最后部分了,为了不让学习那么枯燥,我们先来讲点小故事。 - -在1965年,当时还处于批处理操作系统的时代,但是它只能同时供一个用户使用,而当时人们正希望能够开发一种交互式的、具有多道程序处理能力的分时操作系统。于是,贝尔实验室、美国麻省理工学院和通用电气公司联合发起了一项名为 Multics 的工程计划,而目的也是希望能够开发出这样的一个操作系统,但是最终由于各种原因以失败告终。 - -以肯•汤普森为首的贝尔实验室研究人员吸取了 Multics 工程计划失败的经验教训,于 1969 年实现了分时操作系统的雏形,在1970 年该操作系统正式取名为**UNIX**,它是一个强大的多用户、多任务操作系统,支持多种处理器架构,1973 年,也就是C语言问世不久后,UNIX操作系统的绝大部分源代码都用C语言进行了重写。 - -从这之后,大量的UNIX发行版本涌现(基于Unix进行完善的系统)比如 FreeBSD 就是美国加利福尼亚大学伯克利分校开发的 UNIX 版本,它由来自世界各地的志愿者开发和维护,为不同架构的计算机系统提供了不同程度的支持。 - - 而后来1984年苹果公司发布的的MacOS(在Macintosh电脑上搭载)操作系统,正是在 FreeBSD 基础之上开发的全新操作系统,这是首次计算机正式跨进图形化时代,具有里程碑的意义。 - -![Mac OS](https://www.webdesignerdepot.com/cdn-origin/uploads/2009/03/mac-os-1.gif) - -同年,乔布斯非常高兴地将自家的图形化MacOS界面展示给微软创始人比尔盖茨,并且希望微软可以为MacOS开发一些软件。比尔盖茨一看,woc,这玩意牛逼啊,咱们自己也给安排一个。于是,在1985年,微软仿造MacOS并基于MS-DOS操作系统,开发出了名为Windows的操作系统: - -![img](https://pics5.baidu.com/feed/b7003af33a87e950dd561c8ef220e945faf2b4ab.jpeg?token=b2063459ad19e976ed42754e5f9caea8) - -Windows操作系统的问世,无疑是对MacOS的一次打击,因为MacOS只能搭载在Mac上,但是售价实在太贵,并且软件生态也不尽人意,同时代的Windows却能够安装到各种各样的DIY电脑上,称其为PC,尤其是后来的Windows95,几乎是封神的存在,各种各样基于Windows的软件、游戏层出不穷,以至于到今天为止,MacOS的市场占有率依然远低于Windows,不过Apple这十几年一直在注重自家软件生态的发展,总体来说在办公领域体验感其实和Windows差不多,甚至可能还更好,但是打游戏,别想了。 - -说了这么多,Linux呢,怎么一句都没提它呢?最牛逼的当然放最后说(不是 - -Unix虽然强大但是有着昂贵的授权费用,并且不开放源代码,于是有人发起了GNU运动(GNU IS NOT UNIX,带有那么一丝嘲讽),模仿 Unix 的界面和使用方式,从头做一个开源的版本。在1987年荷兰有个大学教授安德鲁写了一个Minix,类似于Unix,专用于教学。当Minix流传开来之后,世界各地的黑客们纷纷开始使用并改进,希望把改进的东西合并到Minix中,但是安德鲁觉得他的系统是用于教学的,不能破坏纯净性,于是拒绝了。 - -在1991年,林纳斯.托瓦兹(Linus Torvalds)认为Minix不够开放,自己又写了一个全新的开源操作系统,它希望这个系统由全世界的爱好者一同参与开发,并且不收费,于是Linux内核就被公开发布到互联网上。一经发布,便引起了社会强烈的反响,在大家的努力下,于1994年Linux的1.0版本正式发布。结合当时的GNU运动,最终合在一起称为了GNU/Linux,以一只企鹅Tux作为吉祥物。 - -![点击查看图片来源](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fnimg.ws.126.net%2F%3Furl%3Dhttp%3A%2F%2Fdingyue.ws.126.net%2F2021%2F0914%2F1833179ap00qzeyx5000cc000go009qg.png%26thumbnail%3D650x2147483647%26quality%3D80%26type%3Djpg&refer=http%3A%2F%2Fnimg.ws.126.net&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1645451358&t=082f4539e97a1a3c9dc8002847e9ad5a) - -没错,Git也是林纳斯.托瓦兹只花了2周时间开发的。不过林纳斯非常讨厌C++,他认为C++只会让一个项目变得混乱。 - -从此以后,各式各样的基于Linux发行版就开始出现: - -![点击查看图片来源](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fqnam.smzdm.com%2F202002%2F08%2F5e3e59003360f5288.jpg_e680.jpg&refer=http%3A%2F%2Fqnam.smzdm.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1645451543&t=f2471b52851d92920194d2ef2acbab6f) - -这些发行版都是在Linux内核的基础之上,添加了大量的额外功能,包括开发环境、图形化桌面、包管理等。包括我们的安卓系统,也是基于Linux之上的,而我们要重点介绍的就是基于Debian之上的Ubuntu操作系统。 - -最后,2022年了,我们再来看一下各大操作系统的市场占有率: - -* Windows11/10/7:80% -* MacOS:11% -* Linux:5% -* 其他:4% - -Windows无疑是现在最广泛的操作系统,尤其是Windows XP,是多少00后的青春,很多游戏都是基于Windows平台。当然,如果你已经厌倦了游戏,一心只读圣贤书的话,那么还是建议直接使用任意Linux桌面版或是Mac,因为它们能够为你提供极致和纯粹的开发体验(貌似之前华为也出过Linux笔记本?) - -*** - -![点击查看图片来源](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fwww.iasptk.com%2Fwp-content%2Fuploads%2Fsites%2F12%2F2017%2F03%2FUbuntu-02804350.jpg&refer=http%3A%2F%2Fwww.iasptk.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1645453389&t=d7908bc10978585c9ce374cc8013ff64) - -## 安装Ubuntu系统 - -这里我们就以安装虚拟机的方式在我们的电脑上安装Linux操作系统,我们选用Ubuntu作为教程,如果有经济实力,可以在腾讯云、阿里云之类的服务商购买一台云服务器,并选择预装Ubuntu系统;如果你还想搞嵌入式开发之类的工作,可以购买一台树莓派服务器,也可以在上面安装Ubuntu系统,相当于一台迷你主机。在你已经有云服务器的情况下,可以直接跳过虚拟机安装教学。 - -官网下载:https://cn.ubuntu.com/download/server/step1 - -注意是下载服务器版本,不是桌面版本。 - -### 在虚拟机中安装 - -这里我们使用VMware进行安装,VMware是一个虚拟化应用程序,它可以在我们当前运行的操作系统之上,创建一个虚线的主机,相当于创建了一台电脑,而我们就可以在这台电脑上安装各种各样的操作系统,并且我们可以自由为其分配CPU核心和内存以及硬盘容量(如果你接触过云计算相关内容,应该会对虚拟化技术有所了解) - -官网下载:https://www.vmware.com/cn/products/workstation-pro/workstation-pro-evaluation.html - -安装完成后,会出现一个类似于CMD的命令窗口,而我们就是通过输入命令来操作我们的操作系统。 - -### 使用SSH远程连接 - -如果你使用的是树莓派或是云服务器,那么你会得到一个公网的IP地址,以及默认的用户名和密码,由于服务器安装的Ubuntu并不是在我们的电脑上运行的,那么我们怎么去远程操作呢? - -比如我们要远程操作一台Windows电脑,直接使用远程桌面连接即可,但是Ubuntu上来就是命令行,这种情况下要实现远程连接就只能使用SSH终端。 - -SSH是一种网络协议,用于计算机之间的加密登录。如果一个用户从本地计算机,使用SSH协议登录另一台远程计算机,我们就可以认为,这种登录是安全的,即使被中途截获,密码也不会泄露。最早的时候,互联网通信都是明文通信,一旦被截获,内容就暴露无疑。1995年,芬兰学者Tatu Ylonen设计了SSH协议,将登录信息全部加密,成为互联网安全的一个基本解决方案,迅速在全世界获得推广,目前已经成为Linux系统的标准配置。 - -云服务器上安装的Ubuntu默认都是自带了OpenSSH服务端的,我们可以直接连接,如果你的Ubuntu服务器上没有安装OpenSSH服务器端,那么可以输入命令进行安装: - -```shell -sudo apt install openssh-server -#输入后还需要你输入当前用户的密码才可以执行,至于为什么我们后面会说 -``` - -这里我们使用XShell来进行SSH登陆,官网:https://www.netsarang.com/zh/free-for-home-school/ - -### 文件系统介绍 - -在Windows下,我们的整个硬盘实际上可以被分为多个磁盘驱动器: - -![在这里插入图片描述](https://img-blog.csdnimg.cn/20190415191752939.png) - -我们一般习惯将软件装到D盘,文件数据存在E盘,系统和一些环境安装在C盘,根据不同的盘符进行划分,并且每个盘都有各自的存储容量大小。而在Linux中,没有这个概念,所有的文件都是位于根目录下的: - -![img](https://upload-images.jianshu.io/upload_images/17163728-8d41eb59e5cfb7ee.png?imageMogr2/auto-orient/strip|imageView2/2/w/904) - -我们可以看到根目录下有很多个文件夹,它们都有着各自的划分: - -* /bin 可执行二进制文件的目录,如常用的命令 ls、tar、mv、cat 等实际上都是一些小的应用程序 -* /home 普通用户的主目录,对应Windows下的C:/Users/用户名/ -* /root root用户的主目录(root用户是具有最高权限的用户,之后会讲) -* /boot 内核文件的引导目录, 放置 linux 系统启动时用到的一些文件 -* /sbing 超级用户使用的指令文件 -* /tmp 临时文件目录,一般用户或正在执行的程序临时存放文件的目录,任何人都可以访问,重要数据不可放置在此目录下。 -* /dev 设备文件目录,在Linux中万物皆文件,实际上你插入的U盘等设备都会在dev目录下生成一个文件,我们可以很方便地通过文件IO方式去操作外设,对嵌入式开发极为友好。 -* /lib 共享库,系统使用的函数库的目录,程序在执行过程中,需要调用一些额外的参数时需要函数库的协助。 -* /usr 第三方 程序目录 -* /etc 配置程序目录,系统配置文件存放的目录 -* /var 可变文件,放置系统执行过程中经常变化的文件 -* /opt 用户使用目录,给主机额外安装软件所摆放的目录。 - -我们可以直接输入命令来查看目录下的所有文件: - -```shell -#只显示文件名称,且不显示隐藏文件 -ls -#显示隐藏文件以及文件详细信息 -ll -``` - -那么我们如何才能像Windows那样方便的管理Linux中的文件呢?我们可以使用FTP管理工具,默认情况下Ubuntu是安装了SFTP服务器的。 - -这里我们使用Xftp来进行管理,官网:https://www.netsarang.com/zh/free-for-home-school/ - -*** - -## 用户和用户组 - -我们整个Linux阶段的学习主要是以实操为主,大量的命令需要大量的使用才能记得更牢固。 - -Linux系统是一个多用户多任务的分时操作系统,任何一个要使用系统的用户,都必须申请一个账号,然后以这个账号的身份进入系统。比如我们之前就是使用我们在创建服务器时申请的初始用户test,通过输入用户名和密码登录到系统中,之后才能使用各种命令进行操作。其实用户机制和我们的Windows比较类似。一般的普通用户只能做一些比较基本的操作,并且只能在自己的目录(如/home/test)中进行文件的创建和删除操作。 - -我们可以看到,当前状态信息分为三段: - -```shell -test@ubuntu-server:~$ -``` - -格式为:用户名@服务器名称:当前所处的目录$,其中~代表用户目录,如果不是用户目录,会显示当前的绝对路径地址。我们也可以使用`pwd`命令来直接查看当前所处的目录。 - -在Linux中默认存在一个超级用户root,而此用户拥有最高执行权限,它能够修改任何的内容,甚至可以删除整个Linux内核,正常情况下不会使用root用户进行登陆,只有在特殊情况下才会使用root用户来进行一些操作,root用户非常危险,哪怕一个小小的命令都能够毁掉整个Linux系统,比如`rm -rf /*`,感兴趣的话我们可以放在最后来演示(在以前老是听说安卓手机root,实际上就是获取安卓系统底层Linux系统的root权限,以实现修改系统文件的目的) - -我们可以使用`sudo -s`并输入当前用户的密码切换到root用户,可以看到出现了一些变化: - -```shell -test@ubuntu-server:~$ - -root@ubuntu-server:/home/test# -``` - -我们发现`$`符号变成了`#`符号,注意此符号表示当前的用户权限等级,并且test也变为了root,在此用户下,我们可以随意修改test用户文件夹以外的内容,而test用户下则无法修改。如果需要退出root用户,直接输入`exit`即可。 - -接着我们来看一下,如何进行用户的管理操作,进行用户管理,包括添加用户和删除用户都需要root权限才可以执行,但是现在我们是test用户,我们可以在命令前面添加`sudo`来暂时以管理员身份执行此命令,比如说我们现在想要添加一个新的用户: - -```shell -sudo useradd study -``` - -其中`study`就是我们想要创建的新用户,`useradd`命令就是创建新用户的命令,同样的,删除用户: - -```sh -sudo userdel study -``` - -Linux中的命令一般都可以携带一些参数来以更多特地的方式执行,我们可以在创建用户时,添加一些额外的参数来进行更多高级操作: - -- -d<登录目录>  指定用户登录时的起始目录。 -- -g<群组>  指定用户所属的群组。 -- -G<群组>  指定用户所属的附加群组。 -- -m  自动建立用户的登入目录。 -- -M  不要自动建立用户的登入目录。 -- -s 指定Shell,一般指定为/bin/bash - -如果还想查看更多命令,可以直接使用`man`来查看命令的详细参数列表,比如: - -```shell -man useradd -``` - -比如我们现在需要在用户创建时顺便创建用户的文件夹,并指定shell(任意一种命令解释程序,用于处理我们输入的命令)为bash: - -```shell -sudo useradd study -m -s /bin/bash -``` - -可以看到已经自动在home目录下创建了study文件夹(这里..表示上一级目录,.表示当前目录): - -```shell -test@ubuntu-server:~$ ls .. -study test -``` - -用户创建完成之后,我们可以为此用户设定密码(如果不指定用户,那么会设置当前用户的密码): - -```shell -sudo passwd study -``` - -输入密码之后,我们可以使用命令来切换用户: - -```shell -test@ubuntu-server:~$ su - study -Password: -study@ubuntu-server:~$ -``` - -可以看到,切换用户后名称已经修改为study了,我们使用`exit`即可退出当前用户回到test。 - -输入`who`可以查看当前登录账号(注意是登录的账号)输入`whoami`可以查看当前的操作账号: - -```shell -test@ubuntu-server:~$ su study -Password: -study@ubuntu-server:/home/test$ cd ~ -study@ubuntu-server:~$ who -test pts/0 2022-01-24 03:57 (192.168.10.3) -study@ubuntu-server:~$ whoami -study -study@ubuntu-server:~$ -``` - -接着我们来看用户组,每个用户都有一个用户组,系统可以对一个用户组中的所有用户进行集中管理。我们可以输入`groups`来查看当前用户所有的用户组: - -```sh -test@ubuntu-server:~$ groups -test adm cdrom sudo dip plugdev lxd -``` - -我们可以输入`id`来查看用户所属的用户相关信息: - -```sh -test@ubuntu-server:~$ id -uid=1000(test) gid=1000(test) groups=1000(test),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),116(lxd) -``` - -我们可以看到test用户默认情况下主要用户组为同名的test用户组,并且还属于一些其他的用户组,其中sudo用户组就表示可以执行`sudo`命令,我们发现我们创建的study用户没有sudo的执行权限: - -```sh -study@ubuntu-server:~$ sudo -s -[sudo] password for study: -study is not in the sudoers file. This incident will be reported. -``` - -正是因为没有加入到sudo用户组,这里我们来尝试将其添加到sudo用户组: - -```sh -test@ubuntu-server:~$ id study -uid=1001(study) gid=1001(study) groups=1001(study) -``` - -使用`usermod`命令来对用户的相关设置进行修改,参数与useradd大致相同: - -```sh -test@ubuntu-server:~$ sudo usermod study -G sudo -test@ubuntu-server:~$ id study -uid=1001(study) gid=1001(study) groups=1001(study),27(sudo) -``` - -接着切换到study用户就可以使用sudo命令了: - -```sh -To run a command as administrator (user "root"), use "sudo ". -See "man sudo_root" for details. - -study@ubuntu-server:/home/test$ sudo -s -[sudo] password for study: -root@ubuntu-server:/home/test# -``` - -实际上,我们的用户信息是存储在配置文件中的,我们之前说了,配置文件一般都放在etc目录下,而用户和用户组相关的配置文件,存放在`/etc/passwd`和`/etc/group`中,我们可以使用cat命令将文件内容打印到控制台: - -```bash -test@ubuntu-server:~$ cat /etc/passwd -root:x:0:0:root:/root:/bin/bash -daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin -bin:x:2:2:bin:/bin:/usr/sbin/nologin -sys:x:3:3:sys:/dev:/usr/sbin/nologin -sync:x:4:65534:sync:/bin:/bin/sync -``` - -格式为:`注册名:口令:用户标识号:组标识号:用户名:用户主目录:命令解释程序 `,而我们的密码则存放在`/etc/shadow`中,是以加密形式存储的,并且需要root权限才能查看。 - -*** - -## 常用命令 - -接着我们来看一下Linux系统中一些比较常用的命令。 - -### 文件操作 - -文件是最基本的内容,我们可以使用ls命令列出当前目录中所有的文件,参数-a表示包含所有的隐藏文件,-l表示列出详细信息: - -```sh -test@ubuntu-server:~$ ls -al -total 44 -drwxr-xr-x 4 test test 4096 Jan 24 08:55 . -drwxr-xr-x 4 root root 4096 Jan 24 04:24 .. --rw------- 1 test test 2124 Jan 24 04:29 .bash_history --rw-r--r-- 1 test test 220 Feb 25 2020 .bash_logout --rw-r--r-- 1 test test 3771 Feb 25 2020 .bashrc -drwx------ 2 test test 4096 Jan 21 15:48 .cache -drwx------ 3 test test 4096 Jan 23 14:49 .config --rw-r--r-- 1 test test 807 Feb 25 2020 .profile --rw------- 1 test test 34 Jan 24 04:17 .python_history --rw-r--r-- 1 test test 0 Jan 21 15:52 .sudo_as_admin_successful --rw------- 1 test test 7201 Jan 24 08:55 .viminfo -``` - -可以看到当前目录下的整个文件列表,那么这些信息各种代表什么意思呢,尤其是最前面那一栏类似于`drwxr-xr-x`的字符串。 - -它表示文件的属性,其中第1个字符表示此文件的类型:`-`表示普通文件,`l`为链接文件,`d`表示目录(文件夹),`c`表示字符设备、`b`表示块设备,还有`p`有名管道、`f`堆栈文件、`s`套接字等,这些一般都是用于进程之间通信使用的。 - -第2-4个字符表示文件的拥有者(User)对该文件的权限,第5-7个字符表示文件所属用户组(Group)内用户对该文件的权限,最后8-10个字符表示其他用户(Other)对该文件的权限。其中`r`为读权限、`w`为写权限、`x`为执行权限,为了方便记忆,直接记UGO就行了。 - -比如`drwxr-xr-x`就表示这是一个目录,文件的拥有者可以在目录中读、写和执行,而同一用户组的其他用户只能读和执行,其他用户也是一样。 - -第二栏数据可以看到是一列数字,它表示文件创建的链接文件(快捷方式)数量,一般只有1表示只有当前文件,我们也可以尝试创建一个链接文件: - -```sh -test@ubuntu-server:~$ ln .bash_logout kk -``` - -创建后,会生成一个名为kk的文件,我们对此文件的操作相当于直接操作.bash_logout,跟Windows中的快捷方式比较类似,了解一下即可。再次执行`ll`命令,可以看到.bash_logout的链接数变成了2。 - -第三栏数据为该文件或是目录的拥有者。 - -第四栏数据表示所属的组。 - -第五栏数据表示文件大小,以字节为单位。 - -第六栏数据为文件的最后一次修改时间 - -最后一栏就是文件名称了,就不多说了,再次提及..表示上级目录,.表示当前目录,最前面有一个.开头的文件为隐藏文件。可以看到上级目录(也就是/home目录)所有者为root,并且非root用户无法进行写操作,只能执行读操作,而当前目录以及目录下所有文件则属于test用户,test用户可以随意进行修改。 - -在了解了Linux的文件查看之后再去看Windows的文件管理,会觉得Windows的太拉了: - -![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg0.pconline.com.cn%2Fpconline%2F2107%2F02%2F14306786_05_thumb.jpg&refer=http%3A%2F%2Fimg0.pconline.com.cn&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1645608268&t=75ea125c46cb38b22268156794b986d4) - -那么,如果我们希望对文件的属性进行修改,比如我们现在希望将某个文件的写权限给关闭,可以使用`chmod`命令来进行文件属性修改,我们先创建一个test文件,使用`touch`命令来创建文件,使用`mkdir`命令来创建目录: - -```shell -test@ubuntu-server:~$ touch test -test@ubuntu-server:~$ ll test --rw-rw-r-- 1 test test 0 Jan 24 09:32 test -``` - -可以看到文件创建之后的默认权限为可读可写,接着我们来将其修改为只读,chmod的使用方法如下: - -* chmod (u/g/o/a)(+/-)(r/w/x) 文件名称 - -我们可以从ugo中选择或是直接a表示所有,+和-表示添加和删除权限,最后rwx不用我说了吧 - -```sh -test@ubuntu-server:~$ chmod a-w test -test@ubuntu-server:~$ ll test --r--r--r-- 1 test test 0 Jan 24 09:32 test -``` - -除了这种方式之外,我们也可以使用数字来代替,比如现在我要给前两个添加读权限,那么: - -约定:r=4,w=2,x=1,需要什么权限就让对应权限的数字相加,一个数字表示一个rwx的权限状态,比如我们想修改为`-rw-rw-r--`,那么对应的数字就是`664`,对应的命令为: - -```sh -test@ubuntu-server:~$ chmod 664 test -test@ubuntu-server:~$ ll test --rw-rw-r-- 1 test test 0 Jan 24 09:32 test -``` - -如果我们想修改文件的拥有者或是所属组,可以使用`chown`和`chgrp`命令: - -```sh -test@ubuntu-server:~$ sudo chown root test -test@ubuntu-server:~$ ls -l -total 0 --rw-rw-r-- 1 root test 0 Jan 24 10:43 test -test@ubuntu-server:~$ sudo chgrp root test -test@ubuntu-server:~$ ls -l -total 0 --rw-rw-r-- 1 root root 0 Jan 24 10:43 test -``` - -再次操作该文件,会发现没权限: - -```sh -test@ubuntu-server:~$ chmod 777 test -chmod: changing permissions of 'test': Operation not permitted -``` - -接着我们来看文件的复制、移动和删除,这里我们先创建一个新的目录并进入到此目录用于操作: - -```sh -test@ubuntu-server:~$ mkdir study -test@ubuntu-server:~$ cd study -test@ubuntu-server:~/study$ -``` - -首先我们演示文件的复制操作,文件的复制使用`cp`命令,比如现在我们想把上一级目录中的test文件复制到当前目录中: - -```sh -test@ubuntu-server:~/study$ cp ../test test -test@ubuntu-server:~/study$ ls -test -``` - -那么如果我们想要将一整个目录进行复制呢?我们需要添加一个`-r`参数表示将目录中的文件递归复制: - -```sh -test@ubuntu-server:~/study$ cd ~ -test@ubuntu-server:~$ cp -r study study_copied -test@ubuntu-server:~$ ls -l -total 8 -drwxrwxr-x 2 test test 4096 Jan 24 10:16 study -drwxrwxr-x 2 test test 4096 Jan 24 10:20 study_copied --rw-rw-r-- 1 test test 0 Jan 24 09:32 test -``` - -可以看到我们的整个目录中所有的文件也一起被复制了。 - -接着我们来看看移动操作,相当于是直接将一个文件转移到另一个目录中了,我们再创建一个目录用于文件的移动,并将test文件移动到此目录中,我们使用`mv`命令进行文件的移动: - -```sh -test@ubuntu-server:~$ mkdir study2 -test@ubuntu-server:~$ mv test study2 -test@ubuntu-server:~$ ls -study study2 study_copied -test@ubuntu-server:~$ cd study2 -test@ubuntu-server:~/study2$ ls -test -``` - -现在我们想要移动个目录到另一个目录中,比如我们想将study目录移动到study2目录中: - -```sh -test@ubuntu-server:~$ mv study study2 -test@ubuntu-server:~$ ls -study2 study_copied -test@ubuntu-server:~$ cd study2 -test@ubuntu-server:~/study2$ ls -study test -``` - -`mv`命令不仅能实现文件的移动,还可以实现对文件重命名操作,比如我们想将文件test重命名为yyds,那么直接将其进行移动操作即可: - -```sh -test@ubuntu-server:~/study2$ ls -study test -test@ubuntu-server:~/study2$ mv test yyds -test@ubuntu-server:~/study2$ ls -study yyds -``` - -最后就是删除命令了,使用`rm`进行删除操作,比如现在我们想删除study2目录(注意需要添加-r参数表示递归删除文件夹中的内容): - -```sh -test@ubuntu-server:~$ rm -r study2 -test@ubuntu-server:~$ ls -study_copied -``` - -而最常提到的`rm -rf /*`正是删除根目录下所有的文件(非常危险的操作),-f表示忽略不存在的文件,不进行任何提示,*是一个通配符,表示任意文件。这里我们演示一下删除所有.txt结尾的文件: - -```sh -test@ubuntu-server:~$ touch 1.txt 2.txt 3.txt -test@ubuntu-server:~$ ls -1.txt 2.txt 3.txt -test@ubuntu-server:~$ rm *.txt -test@ubuntu-server:~$ ls -test@ubuntu-server:~$ -``` - -最后我们再来看文件的搜索,我们使用find命令来进行搜索,比如我想搜索/etc目录下名为passwd的文件: - -``` -test@ubuntu-server:~$ sudo find /etc -name passwd -[sudo] password for test: -/etc/pam.d/passwd -/etc/passwd -``` - -它还支持通配符,比如搜索以s开头的文件: - -``` -test@ubuntu-server:~$ sudo find /etc -name s* -/etc/subuid -/etc/screenrc -/etc/sensors3.conf -/etc/sysctl.conf -/etc/sudoers -/etc/shadow -/etc/skel -/etc/pam.d/su -/etc/pam.d/sshd -/etc/pam.d/sudo -... -``` - -### 系统管理 - -接着我们来查看一些系统管理相关的命令,比如我们Windows中的任务管理器,我们可以使用`top`命令来打开: - -``` -top - 10:48:46 up 5:52, 1 user, load average: 0.00, 0.00, 0.00 -Tasks: 191 total, 2 running, 189 sleeping, 0 stopped, 0 zombie -%Cpu(s): 0.0 us, 0.2 sy, 0.0 ni, 99.8 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st -MiB Mem : 3919.1 total, 2704.2 free, 215.0 used, 999.9 buff/cache -MiB Swap: 3923.0 total, 3923.0 free, 0.0 used. 3521.4 avail Mem - - PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND - 10528 test 20 0 8944 3072 2652 R 0.7 0.1 0:00.07 top - 9847 root 20 0 0 0 0 I 0.3 0.0 0:00.87 kworker/0:0-events - 1 root 20 0 102760 10456 7120 S 0.0 0.3 0:02.02 systemd - 2 root 20 0 0 0 0 S 0.0 0.0 0:00.01 kthreadd - 3 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 rcu_gp - 4 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 rcu_par_gp - 6 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 kworker/0:0H-kblockd - 8 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 mm_percpu_wq - 9 root 20 0 0 0 0 S 0.0 0.0 0:00.15 ksoftirqd/0 - 10 root 20 0 0 0 0 R 0.0 0.0 0:01.49 rcu_sched - 11 root rt 0 0 0 0 S 0.0 0.0 0:00.24 migration/0 - 12 root -51 0 0 0 0 S 0.0 0.0 0:00.00 idle_inject/0 - 14 root 20 0 0 0 0 S 0.0 0.0 0:00.00 cpuhp/0 - 15 root 20 0 0 0 0 S 0.0 0.0 0:00.00 cpuhp/1 - 16 root -51 0 0 0 0 S 0.0 0.0 0:00.00 idle_inject/1 - 17 root rt 0 0 0 0 S 0.0 0.0 0:00.30 migration/1 - 18 root 20 0 0 0 0 S 0.0 0.0 0:00.07 ksoftirqd/1 - 20 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 kworker/1:0H-kblockd -``` - -可以很清楚地看到当前CPU的使用情况以及内存的占用情况。 - -按下数字键1,可以展示所有CPU核心的使用情况: - -``` -%Cpu0 : 0.0 us, 0.0 sy, 0.0 ni,100.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st -%Cpu1 : 0.0 us, 0.0 sy, 0.0 ni,100.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st -``` - -按下f键可以设置以哪一列进行排序或是显示那些参数: - -``` -Fields Management for window 1:Def, whose current sort field is %MEM - Navigate with Up/Dn, Right selects for move then or Left commits, - 'd' or toggles display, 's' sets sort. Use 'q' or to end! -``` - -按下q键即可退出监控界面。 - -我们可以直接输入free命令来查看当前系统的内存使用情况: - -``` -test@ubuntu-server:~$ free -m - total used free shared buff/cache available -Mem: 3919 212 2706 1 999 3523 -Swap: 3922 0 3922 -``` - -其中-m表示以M为单位,也可以-g表示以G为单位,默认是kb为单位。 - -最后就是磁盘容量,我们可以使用`lsblk`来查看所有块设备的信息,其中就包括我们的硬盘、光驱等: - -``` -test@ubuntu-server:~$ lsblk -NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT -loop0 7:0 0 48.9M 1 loop /snap/core18/2127 -loop1 7:1 0 28.1M 1 loop /snap/snapd/12707 -loop2 7:2 0 62M 1 loop /snap/lxd/21032 -sr0 11:0 1 1024M 0 rom -nvme0n1 259:0 0 20G 0 disk -├─nvme0n1p1 259:1 0 512M 0 part /boot/efi -├─nvme0n1p2 259:2 0 1G 0 part /boot -└─nvme0n1p3 259:3 0 18.5G 0 part - └─ubuntu--vg-ubuntu--lv 253:0 0 18.5G 0 lvm / -``` - -可以看到nvme开头的就是我们的硬盘(这个因人而异,可能你们的是sda,磁盘类型不同名称就不同)可以看到`nvme0n1 `容量为20G,并且512M用作存放EFI文件,1G存放启动文件,剩余容量就是存放系统文件和我们的用户目录。 - -这里要提到一个挂载的概念: - -> 挂载,指的就是将设备文件中的顶级目录连接到 Linux 根目录下的某一目录(最好是空目录),访问此目录就等同于访问设备文件。 - -比如我们的主硬盘,挂载点就被设定为`/`根目录,而我们所有保存的文件都会存储在硬盘中,如果你有U盘(最好将U盘的文件格式改为ExFat,可以直接在Windows中进行格式化,然后随便放入一些文件即可)之类的东西,我们可以演示一下对U盘进行挂载: - -```sh -test@ubuntu-server:~$ sudo fdisk -l -... -Disk /dev/sda: 60 GiB, 64424509440 bytes, 125829120 sectors -Disk model: USB DISK -Units: sectors of 1 * 512 = 512 bytes -Sector size (logical/physical): 512 bytes / 512 bytes -I/O size (minimum/optimal): 512 bytes / 512 bytes -Disklabel type: dos -Disk identifier: 0x4a416279 - -Device Boot Start End Sectors Size Id Type -/dev/sda1 * 614400 125214719 124600320 59.4G 7 HPFS/NTFS/exFAT -/dev/sda2 125214720 125825022 610303 298M 6 FAT16 -``` - -将U盘插入电脑,选择连接到Linux,输入`sudo fdisk -l`命令来查看硬盘实体情况,可以看到有一个USB DISK设备,注意观察一下是不是和自己的U盘容量一致,可以看到设备名称为`/dev/sda1`。 - -接着我们设备挂载到一个目录下: - -```sh -test@ubuntu-server:~$ mkdir u-test -test@ubuntu-server:~$ sudo mount /dev/sda1 u-test/ -test@ubuntu-server:~$ cd u-test/ -test@ubuntu-server:~/u-test$ ls - CGI - cn_windows_10_enterprise_ltsc_2019_x64_dvd_9c09ff24.iso - cn_windows_7_professional_x64_dvd_x15-65791.iso - cn_windows_8.1_enterprise_with_update_x64_dvd_6050374.iso - cn_windows_8.1_professional_vl_with_update_x64_dvd_4050293.iso - cn_windows_server_2019_updated_july_2020_x64_dvd_2c9b67da.iso -'System Volume Information' - zh-cn_windows_10_consumer_editions_version_21h1_updated_sep_2021_x64_dvd_991b822f.iso - zh-cn_windows_11_consumer_editions_x64_dvd_904f13e4.iso -``` - -最后进入到此目录中,就能看到你U盘中的文件了,如果你不想使用U盘了,可以直接取消挂载: - -``` -test@ubuntu-server:~/u-test$ cd .. -test@ubuntu-server:~$ sudo umount /dev/sda1 -``` - -最后我们可以通过`df`命令查看当前磁盘使用情况: - -``` -test@ubuntu-server:~$ df -m -Filesystem 1M-blocks Used Available Use% Mounted on -udev 1900 0 1900 0% /dev -tmpfs 392 2 391 1% /run -/dev/mapper/ubuntu--vg-ubuntu--lv 18515 6544 11009 38% / -tmpfs 1960 0 1960 0% /dev/shm -tmpfs 5 0 5 0% /run/lock -tmpfs 1960 0 1960 0% /sys/fs/cgroup -/dev/nvme0n1p2 976 109 800 12% /boot -/dev/nvme0n1p1 511 4 508 1% /boot/efi -/dev/loop0 49 49 0 100% /snap/core18/2127 -/dev/loop1 29 29 0 100% /snap/snapd/12707 -/dev/loop2 62 62 0 100% /snap/lxd/21032 -tmpfs 392 0 392 0% /run/user/1000 -``` - -输入`ps`可以查看当前运行的一些进程,其实和top有点类似,但是没有监控功能,只能显示当前的。 - -``` -test@ubuntu-server:~$ ps - PID TTY TIME CMD - 11438 pts/0 00:00:00 bash - 11453 pts/0 00:00:00 ps -``` - -添加-ef查看所有的进程: - -``` -test@ubuntu-server:~$ ps -ef -UID PID PPID C STIME TTY TIME CMD -root 1 0 0 04:55 ? 00:00:02 /sbin/init -root 2 0 0 04:55 ? 00:00:00 [kthreadd] -root 3 2 0 04:55 ? 00:00:00 [rcu_gp] -root 4 2 0 04:55 ? 00:00:00 [rcu_par_gp] -root 6 2 0 04:55 ? 00:00:00 [kworker/0:0H-kblockd] -... -``` - -我们可以找到对应的进程ID(PID),使用kill命令将其强制终止: - -``` -test@ubuntu-server:~$ ps - PID TTY TIME CMD - 11438 pts/0 00:00:00 bash - 11455 pts/0 00:00:00 ps -test@ubuntu-server:~$ kill -9 11438 -Connection to 192.168.10.6 closed. -``` - -比如我们可以将当前会话的bash给杀死,那么会导致我们的连接直接断开,其中-9是一个信号,表示杀死进程: - -- 1 (HUP):重新加载进程。 -- 9 (KILL):杀死一个进程。 -- 15 (TERM):正常停止一个进程。 - -最后如果我们想要正常关机,只需要输入shutdown即可,系统会创建一个关机计划,并在指定时间关机,或是添加now表示立即关机: - -``` -test@ubuntu-server:~$ sudo shutdown -[sudo] password for test: -Shutdown scheduled for Mon 2022-01-24 11:46:18 UTC, use 'shutdown -c' to cancel. -test@ubuntu-server:~$ sudo shutdown now -Connection to 192.168.10.6 closed by remote host. -Connection to 192.168.10.6 closed. -``` - -### 压缩解压 - -比较常用的压缩和解压也是重点,我们在Windows中经常需要下载一些压缩包,并且将压缩包解压才能获得里面的文件,而Linux中也支持文件的压缩和解压。 - -这里我们使用`tar`命令来完成文件亚索和解压操作,在Linux中比较常用的是gzip格式,后缀名一般为.gz,tar命令的参数-c表示对文件进行压缩,创建新的压缩文件,-x表示进行解压操作,-z表示以gzip格式进行操作,-v可以在处理过程中输出一些日志信息,-f表示对普通文件进行操作,这里我们创建三个文件并对这三个文件进行打包: - -```sh -test@ubuntu-server:~$ tar -zcvf test.tar.gz *.txt -1.txt -2.txt -3.txt -test@ubuntu-server:~$ ls -1.txt 2.txt 3.txt test.tar.gz -test@ubuntu-server:~$ -``` - -接着我们删除刚刚三个文件,再执行解压操作,得到压缩包中文件: - -```sh -test@ubuntu-server:~$ rm *.txt -test@ubuntu-server:~$ ls -test.tar.gz -test@ubuntu-server:~$ tar -zxvf test.tar.gz -1.txt -2.txt -3.txt -test@ubuntu-server:~$ ls -1.txt 2.txt 3.txt test.tar.gz -``` - -同样的,我们也可以对一个文件夹进行打包: - -```sh -test@ubuntu-server:~$ mv *.txt test -test@ubuntu-server:~$ tar -zcvf test.tar.gz test/ -test/ -test/1.txt -test/2.txt -test/3.txt -test@ubuntu-server:~$ rm -r test -test@ubuntu-server:~$ ls -test.tar.gz -test@ubuntu-server:~$ tar -zxvf test.tar.gz -test/ -test/1.txt -test/2.txt -test/3.txt -test@ubuntu-server:~$ ls -test test.tar.gz -test@ubuntu-server:~$ ls test -1.txt 2.txt 3.txt -``` - -到此,Linux的一些基本命令就讲解为止。 - -*** - -## vim文本编辑器 - -和Windows中的记事本一样,Linux中也有文本编辑器,叫做Vi编辑器,Ubuntu中内置了Vi编辑器的升级版Vim,我们这里就讲解Vim编辑器的使用。 - -我们可以直接输入`vim 文件名称`来使用Vim编辑器对文本文件进行编辑: - -```sh -test@ubuntu-server:~$ vim hello.txt -``` - -进入编辑器之后,我们发现界面变成了: - -``` -~ -~ -~ -~ -~ -~ -~ -~ -~ -~ -~ -~ -"hello.txt" [New File] 0,0-1 All -``` - -这时我们直接输入内容是无法完成的,因为默认进入之后为`命令模式`,Vim编辑器默认有三种模式: - -![img](http://c.biancheng.net/uploads/allimg/180914/1-1P9141F35R32.jpg) - -* 命令模式:此模式下可以输入任意的命令进行操作,所有的输入都被看做是命令输入,而不是文本编辑输入。 -* 编辑模式:此模式下输入的任何内容都会以文本编辑方式写入到文件中,就像我们直接在Windows的记事本中写内容一样。 -* 末行模式:此模式下用于输入一些复杂命令,会在最后一行进行复杂命令的输入。 - -在命令模式下,我们可以直接按下键盘上的`i`,此命令表示进行插入操作,会自动切换到编辑模式,这时可以看到最下方变为: - -``` -~ -~ -~ -~ -~ -~ -~ --- INSERT -- 1,1 All -``` - -而这时我们所有的输入内容都可以直接写到文件中了,如果我们想回到命令模式,按下`Esc`键即可。 - -除了`i`以外,我们也可以按下`a`表示从当前光标所在位置之后继续写,与`i`不同的是,`i`会在光标之前继续写,`o`会直接跳到下一行,而`A`表示在当前行的最后继续写入,`I`表示在当前行的最前面继续写入。 - -这里我们随便粘贴一段文本信息进去(不要用Ctrl+V,Linux中没这操作,XShell右键点粘贴): - -``` -I was hard on people sometimes, probably harder than I needed to be. -I remember the time when Reed was six years old, coming home, and I had just fired somebody that day. -And I imagined what it was like for that person to tell his family and his young son that he had lost his job. -It was hard. -But somebody’s got to do it. -I figured that it was always my job to make sure that the team was excellent, and if I didn’t do it, nobody was going to do it. -You always have to keep pushing to innovate. -Dylan could have sung protest songs forever and probably made a lot of money, but he didn’t. -He had to move on, and when he did, by going electric in 1965, he alienated a lot of people. -``` - -在我们编辑完成之后,需要进入到末行模式进行文件的保存并退出,按下`:`进入末行模式,再输入wq即可保存退出。 - -接着我们来看一些比较常用的命令,首先是命令模式下的光标移动命令: - -* ^ 直接调到本行最前面 -* $ 直接跳到本行最后面 -* gg 直接跳到第一行 -* [N]G 跳转到第N行 -* [N]方向键 向一个方向跳转N个字符 - -在末行模式下,常用的复杂命令有: - -* :set number 开启行号 -* :w 保存 -* :wq或:x 保存并关闭 -* :q 关闭 -* :q! 强制关闭 - -我们可以输入`/`或是`?`在末行模式中使用搜索功能,比如我们要搜索单词`it`: - -``` -/it -``` - -接着会在文本中出现高亮,按`n`跳转到下一个搜索结果,?是从后向前搜索,/是从前向后搜索。 - -它还支持替换功能,但是使用起来稍微比较复杂,语法如下: - -``` -:[addr]s/源字符串/目的字符串/[option] -``` - -addr表示第几行或是一个范围,option表示操作类型: - -* g: globe,表示全局替换 -* c: confirm,表示进行确认 -* p: 表示替代结果逐行显示(Ctrl + L恢复屏幕) -* i: ignore,不区分大小写 - -比如我们要将当前行中的`it`全部替换为`he`,那么可以这样写: - -``` -:s/it/he/g -``` - -实际上除了以上三种模式外,还有一种模式叫做可视化模式,按下键盘上的`v`即可进入,它能够支持选取一段文本,选取后,我们可以对指定段落的文本内容快速进行复制、剪切、删除、插入等操作,非常方便。在此模式下,我们可以通过上下左右键进行选取,以进入可视化模式时的位置作为基本位置,通过移动另一端来进行选取。 - -我们可以使用以下命令来对选中区域进行各种操作: - -* y 复制选中区域 -* d/x 剪切(删除)选中区域 -* p 粘贴 -* u 撤销上一步 - -当然,这些命令在命令模式下也可以使用,但是可视化模式下使用更适合一些。 - -*** - -## 环境安装和项目部署 - -在学习完了Linux操作系统的一些基本操作之后,我们接着来看如何进行项目的环境安装和部署,包括安装JDK、Nginx服务器,以及上传我们的SpringBoot项目并运行。 - -我们可以直接使用apt进行软件的安装,它是一个高级的安装包管理工具,我们可以直接寻找对应的软件进行安装,无需再去官网进行下载,非常方便,软件仓库中默认已经帮助我们存放了大量实用软件的安装包,只需要一个安装命令就可以进行安装了。 - -实际上Ubuntu系统已经为我们自带了一些环境了,比如Python3: - -``` -test@ubuntu-server:~$ python3 -Python 3.8.10 (default, Nov 26 2021, 20:14:08) -[GCC 9.3.0] on linux -Type "help", "copyright", "credits" or "license" for more information. ->>> print("HelloWorld!") -HelloWorld! ->>> exit() -``` - -C语言的编译工具GCC可以通过APT进行安装: - -```sh -sudo apt install gcc -``` - -安装后,可以编写一个简单的C语言程序并且编译为可执行文件: - -```c -#include - -int main(){ - printf("Hello World!\n"); -} -``` - -```sh -test@ubuntu-server:~$ vim hello.c -test@ubuntu-server:~$ gcc hello.c -o hello -test@ubuntu-server:~$ ./hello -Hello World! -``` - -而JDK实际上安装也非常简单,通过APT即可: - -```sh -test@ubuntu-server:~$ sudo apt install openjdk-8-j -openjdk-8-jdk openjdk-8-jre openjdk-8-jre-zero -openjdk-8-jdk-headless openjdk-8-jre-headless -test@ubuntu-server:~$ sudo apt install openjdk-8-jdk -``` - -接着我们来测试一下编译和运行,首先编写一个Java程序: - -``` -test@ubuntu-server:~$ vim Main.java -``` - -``` -public class Main{ - public static void main(String[] args){ - System.out.println("Hello World!"); - } -} -``` - -``` -test@ubuntu-server:~$ javac Main.java -test@ubuntu-server:~$ ls -Main.class Main.java -test@ubuntu-server:~$ java Main -Hello World! -``` - -接着我们来部署一下Redis服务器: - -``` -test@ubuntu-server:~$ sudo apt install redis -``` - -安装完成后,可以直接使用`redis-cli`命令打开Redis客户端连接本地的服务器: - -``` -test@ubuntu-server:~$ redis-cli -127.0.0.1:6379> keys * -(empty list or set) -``` - -使用和之前Windows下没有区别。 - -接着我们安装一下MySQL服务器,同样的,直接使用apt即可: - -``` -sudo apt install mysql-server-8.0 -``` - -我们直接直接登录MySQL服务器,注意要在root权限下使用,这样就不用输入密码了: - -``` -sudo mysql -u root -p -Enter password: -Welcome to the MySQL monitor. Commands end with ; or \g. -Your MySQL connection id is 11 -Server version: 8.0.27-0ubuntu0.20.04.1 (Ubuntu) - -Copyright (c) 2000, 2021, Oracle and/or its affiliates. - -Oracle is a registered trademark of Oracle Corporation and/or its -affiliates. Other names may be trademarks of their respective -owners. - -Type 'help;' or '\h' for help. Type '\c' to clear the current input statement. - -mysql> exit -``` - -可以发现实际上就是我们之前在Windows的CMD中使用的样子,接着我们就创建一个生产环境下使用的数据库: - -``` -mysql> create database book_manage; -mysql> show databases; -+--------------------+ -| Database | -+--------------------+ -| book_manage | -| information_schema | -| mysql | -| performance_schema | -| sys | -+--------------------+ -5 rows in set (0.01 sec) -``` - -接着我们创建一个用户来使用这个数据,一会我们就可以将SpringBoot配置文件进行修改并直接放到此服务器上进行部署。 - -``` -mysql> create user test identified by '123456'; -Query OK, 0 rows affected (0.01 sec) - -mysql> grant all on book_manage.* to test; -Query OK, 0 rows affected (0.00 sec) -``` - -如果觉得这样很麻烦不是可视化的,可以使用Navicat连接进行操作,注意开启一下MySQL的外网访问。 - -``` -test@ubuntu-server:~$ mysql -u test -p -Enter password: -Welcome to the MySQL monitor. Commands end with ; or \g. -Your MySQL connection id is 13 -Server version: 8.0.27-0ubuntu0.20.04.1 (Ubuntu) - -Copyright (c) 2000, 2021, Oracle and/or its affiliates. - -Oracle is a registered trademark of Oracle Corporation and/or its -affiliates. Other names may be trademarks of their respective -owners. - -Type 'help;' or '\h' for help. Type '\c' to clear the current input statement. - -mysql> show databases; -+--------------------+ -| Database | -+--------------------+ -| book_manage | -| information_schema | -+--------------------+ -2 rows in set (0.01 sec) -``` - -使用test用户登录之后,查看数据库列表,有book_manage就OK了。 - -最后我们修改一下SpringBoot项目的生产环境配置即可: - -```yaml -spring: - mail: - host: smtp.163.com - username: javastudy111@163.com - password: TKPGLAPDSWKGJOWK - datasource: - url: jdbc:mysql://localhost:3306/book_manage - driver-class-name: com.mysql.cj.jdbc.Driver - username: test - password: 123456 - jpa: - show-sql: false - hibernate: - ddl-auto: update -springfox: - documentation: - enabled: false -``` - -然后启动我们的项目: - -```sh -test@ubuntu-server:~$ java -jar springboot-project-0.0.1-SNAPSHOT.jar -``` - -现在我们将前端页面的API访问地址修改为我们的SpringBoot服务器地址,即可正常使用了。 - -我们也可以将我们的静态资源使用Nginx服务器进行代理: - -> Nginx("engine x")是一款是由俄罗斯的程序设计师Igor Sysoev所开发高性能的 Web和 反向代理 服务器,也是一个 IMAP/POP3/SMTP 代理服务器。 在高连接并发的情况下,Nginx是Apache服务器不错的替代品。 - -Nginx非常强大,它能够通提供非常方便的反向代理服务,并且支持负载均衡,不过我们这里用一下反向代理就可以了,实际上就是代理我们的前端页面,然后我们访问Nginx服务器即可访问到静态资源,这样我们前后端都放在了服务器上(你也可以搞两台服务器,一台挂静态资源一台挂SpringBoot服务器,实现真正意义上的分离,有条件的还能上个域名和证书啥的)。 - -安装如下: - -``` -test@ubuntu-server:~$ sudo apt install nginx -``` - -安装完成后,我们可以直接访问:http://192.168.10.4/,能够出现Nginx页面表示安装成功! - -接着我们将静态资源上传到Linux服务器中,然后对Nginx进行反向代理配置: - -``` -test@ubuntu-server:~$ cd /etc/nginx/ -test@ubuntu-server:/etc/nginx$ ls -conf.d koi-utf modules-available proxy_params sites-enabled win-utf -fastcgi.conf koi-win modules-enabled scgi_params snippets -fastcgi_params mime.types nginx.conf sites-available uwsgi_params -test@ubuntu-server:/etc/nginx$ sudo vim nginx.conf -``` - -``` -server { - listen 80; - server_name 192.168.10.4; - add_header Access-Control-Allow-Origin *; - location / { - root /home/test/static; - charset utf-8; - add_header 'Access-Control-Allow-Origin' '*'; - add_header 'Access-Control-Allow-Credentials' 'true'; - add_header 'Access-Control-Allow-Methods' '*'; - add_header 'Access-Control-Allow-Headers' 'Content-Type,*'; - } - } -``` - -然后就可以直接访问到我们的前端页面了,这时再开启SpringBoot服务器即可,可以在最后添加&符号表示后台启动。 diff --git a/青空笔记/SpringBoot笔记/SpringBoot笔记(四).md b/青空笔记/SpringBoot笔记/SpringBoot笔记(四).md deleted file mode 100644 index 57f3d22..0000000 --- a/青空笔记/SpringBoot笔记/SpringBoot笔记(四).md +++ /dev/null @@ -1,1006 +0,0 @@ -# SpringBoot其他框架 - -通过了解其他的SpringBoot框架,我们就可以在我们自己的Web服务器上实现更多更高级的功能。 - -## 邮件发送:Mail - -我们在注册很多的网站时,都会遇到邮件或是手机号验证,也就是通过你的邮箱或是手机短信去接受网站发给你的注册验证信息,填写验证码之后,就可以完成注册了,同时,网站也会绑定你的手机号或是邮箱。 - -那么,像这样的功能,我们如何实现呢?SpringBoot已经给我们提供了封装好的邮件模块使用: - -```xml - - org.springframework.boot - spring-boot-starter-mail - -``` - -### 邮件发送 - -在学习邮件发送之前,我们需要先了解一下什么是电子邮件。 - -电子邮件也是一种通信方式,是互联网应用最广的服务。通过网络的电子邮件系统,用户可以以非常低廉的价格(不管发送到哪里,都只需负担网费,实际上就是把信息发送到对方服务器而已)、非常快速的方式,与世界上任何一个地方的电子邮箱用户联系。 - -虽说方便倒是方便,虽然是曾经的霸主,不过现在这个时代,QQ微信横行,手机短信和电子邮箱貌似就只剩收验证码这一个功能了。 - -要在Internet上提供电子邮件功能,必须有专门的电子邮件服务器。例如现在Internet很多提供邮件服务的厂商:新浪、搜狐、163、QQ邮箱等,他们都有自己的邮件服务器。这些服务器类似于现实生活中的邮局,它主要负责接收用户投递过来的邮件,并把邮件投递到邮件接收者的电子邮箱中。 - -所有的用户都可以在电子邮件服务器上申请一个账号用于邮件发送和接收,那么邮件是以什么样的格式发送的呢?实际上和Http一样,邮件发送也有自己的协议,也就是约定邮件数据长啥样以及如何通信。 - -![img](https://images2015.cnblogs.com/blog/851491/201612/851491-20161202143243756-1715308358.png) - -比较常用的协议有两种: - -1. SMTP协议(主要用于发送邮件 Simple Mail Transfer Protocol) -2. POP3协议(主要用于接收邮件 Post Office Protocol 3) - -整个发送/接收流程大致如下: - -![img](https://img2.baidu.com/it/u=3675146129,445744702&fm=253&fmt=auto&app=138&f=JPG?w=812&h=309) - -实际上每个邮箱服务器都有一个smtp发送服务器和pop3接收服务器,比如要从QQ邮箱发送邮件到163邮箱,那么我们只需要通过QQ邮箱客户端告知QQ邮箱的smtp服务器我们需要发送邮件,以及邮件的相关信息,然后QQ邮箱的smtp服务器就会帮助我们发送到163邮箱的pop3服务器上,163邮箱会通过163邮箱客户端告知对应用户收到一封新邮件。 - -而我们如果想要实现给别人发送邮件,那么就需要连接到对应电子邮箱的smtp服务器上,并告知其我们要发送邮件。而SpringBoot已经帮助我们将最基本的底层通信全部实现了,我们只需要关心smtp服务器的地址以及我们要发送的邮件长啥样即可。 - -这里以163邮箱 https://mail.163.com 为例,我们需要在配置文件中告诉SpringBootMail我们的smtp服务器的地址以及你的邮箱账号和密码,首先我们要去设置中开启smtp/pop3服务才可以,开启后会得到一个随机生成的密钥,这个就是我们的密码。 - -```yaml -spring: - mail: - # 163邮箱的地址为smtp.163.com,直接填写即可 - host: smtp.163.com - # 你申请的163邮箱 - username: javastudy111@163.com - # 注意密码是在开启smtp/pop3时自动生成的,记得保存一下,不然就找不到了 - password: AZJTOAWZESLMHTNI -``` - -配置完成后,接着我们来进行一下测试: - -```java -@SpringBootTest -class SpringBootTestApplicationTests { - - //JavaMailSender是专门用于发送邮件的对象,自动配置类已经提供了Bean - @Autowired - JavaMailSender sender; - - @Test - void contextLoads() { - //SimpleMailMessage是一个比较简易的邮件封装,支持设置一些比较简单内容 - SimpleMailMessage message = new SimpleMailMessage(); - //设置邮件标题 - message.setSubject("【电子科技大学教务处】关于近期学校对您的处分决定"); - //设置邮件内容 - message.setText("XXX同学您好,经监控和教务巡查发现,您近期存在旷课、迟到、早退、上课刷抖音行为," + - "现已通知相关辅导员,请手写5000字书面检讨,并在2022年4月1日17点前交到辅导员办公室。"); - //设置邮件发送给谁,可以多个,这里就发给你的QQ邮箱 - message.setTo("你的QQ号@qq.com"); - //邮件发送者,这里要与配置文件中的保持一致 - message.setFrom("javastudy111@163.com"); - //OK,万事俱备只欠发送 - sender.send(message); - } - -} -``` - -如果需要添加附件等更多功能,可以使用MimeMessageHelper来帮助我们完成: - -```java -@Test -void contextLoads() throws MessagingException { - //创建一个MimeMessage - MimeMessage message = sender.createMimeMessage(); - //使用MimeMessageHelper来帮我们修改MimeMessage中的信息 - MimeMessageHelper helper = new MimeMessageHelper(message, true); - helper.setSubject("Test"); - helper.setText("lbwnb"); - helper.setTo("你的QQ号@qq.com"); - helper.setFrom("javastudy111@163.com"); - //发送修改好的MimeMessage - sender.send(message); -} -``` - -### 邮件注册 - -既然我们已经了解了邮件发送,那么我们接着来看如何在我们的项目中实现邮件验证。 - -首先明确验证流程:请求验证码 -> 生成验证码(临时有效,注意设定过期时间) -> 用户输入验证码并填写注册信息 -> 验证通过注册成功! - -*** - -![点击查看图片来源](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fpic1.zhimg.com%2Fv2-6f0b9bb234b2534ec295ff195bad183a_1440w.jpg%3Fsource%3D172ae18b&refer=http%3A%2F%2Fpic1.zhimg.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1645108924&t=d40aa5dc6be398725b4ff21ef5895454) - -## 持久层框架:JPA - -* 用了Mybatis之后,你看那个JDBC,真是太逊了。 -* 这么说,你的项目很勇哦? -* 开玩笑,我的写代码超勇的好不好。 -* 阿伟,你可曾幻想过有一天你的项目里不再有SQL语句? -* 不再有SQL语句?那我怎么和数据库交互啊? -* 我看你是完全不懂哦 -* 懂,懂什么啊? -* 你想懂?来,到我项目里来,我给你看点好康的。 -* 好康?是什么新框架哦? -* 什么新框架,比新框架还刺激,还可以让你的项目登duang郎哦。 -* 哇,杰哥,你项目里面都没SQL语句诶,这是用的什么框架啊? - -在我们之前编写的项目中,我们不难发现,实际上大部分的数据库交互操作,到最后都只会做一个事情,那就是把数据库中的数据映射为Java中的对象。比如我们要通过用户名去查找对应的用户,或是通过ID查找对应的学生信息,在使用Mybatis时,我们只需要编写正确的SQL语句就可以直接将获取的数据映射为对应的Java对象,通过调用Mapper中的方法就能直接获得实体类,这样就方便我们在Java中数据库表中的相关信息了。 - -但是以上这些操作都有一个共性,那就是它们都是通过某种条件去进行查询,而最后的查询结果,都是一个实体类,所以你会发现你写的很多SQL语句都是一个套路`select * from xxx where xxx=xxx`,那么能否有一种框架,帮我们把这些相同的套路给封装起来,直接把这类相似的SQL语句给屏蔽掉,不再由我们编写,而是让框架自己去组合拼接。 - -### 认识SpringDataJPA - -首先我们来看一个国外的统计: - -![image-20220119140326867](/Users/nagocoler/Library/Application Support/typora-user-images/image-20220119140326867.png) - -不对吧,为什么Mybatis这么好用,这么强大,却只有10%的人喜欢呢?然而事实就是,在国外JPA几乎占据了主导地位,而Mybatis并不像国内那样受待见,所以你会发现,JPA都有SpringBoot的官方直接提供的starter,而Mybatis没有。 - -至于为啥SSM阶段不讲这个,而是放到现在来讲也是因为,在微服务场景下它的优势才能更多的发挥出来。 - -那么,什么是JPA? - -JPA(Java Persistence API)和JDBC类似,也是官方定义的一组接口,但是它相比传统的JDBC,它是为了实现ORM而生的,即Object-Relationl Mapping,它的作用是在关系型数据库和对象之间形成一个映射,这样,我们在具体的操作数据库的时候,就不需要再去和复杂的SQL语句打交道,只要像平时操作对象一样操作它就可以了。 - -在之前,我们使用JDBC或是Mybatis来操作数据,通过直接编写对应的SQL语句来实现数据访问,但是我们发现实际上我们在Java中大部分操作数据库的情况都是读取数据并封装为一个实体类,因此,为什么不直接将实体类直接对应到一个数据库表呢?也就是说,一张表里面有什么属性,那么我们的对象就有什么属性,所有属性跟数据库里面的字段一一对应,而读取数据时,只需要读取一行的数据并封装为我们定义好的实体类既可以,而具体的SQL语句执行,完全可以交给框架根据我们定义的映射关系去生成,不再由我们去编写,因为这些SQL实际上都是千篇一律的。 - -而实现JPA规范的框架一般最常用的就是`Hibernate`,它是一个重量级框架,学习难度相比Mybatis也更高一些,而SpringDataJPA也是采用Hibernate框架作为底层实现,并对其加以封装。 - -官网:https://spring.io/projects/spring-data-jpa - -### 使用JPA - -同样的,我们只需要导入stater依赖即可: - -```xml - - org.springframework.boot - spring-boot-starter-data-jpa - -``` - -接着我们可以直接创建一个类,比如账户类,我们只需要把一个账号对应的属性全部定义好即可: - -```java -@Data -public class Account { - int id; - String username; - String password; -} -``` - -接着,我们可以通过注解形式,在属性上添加数据库映射关系,这样就能够让JPA知道我们的实体类对应的数据库表长啥样。 - -```java -@Data -@Entity //表示这个类是一个实体类 -@Table(name = "users") //对应的数据库中表名称 -public class Account { - - @GeneratedValue(strategy = GenerationType.IDENTITY) //生成策略,这里配置为自增 - @Column(name = "id") //对应表中id这一列 - @Id //此属性为主键 - int id; - - @Column(name = "username") //对应表中username这一列 - String username; - - @Column(name = "password") //对应表中password这一列 - String password; -} -``` - -接着我们来修改一下配置文件: - -```yaml -spring: - jpa: - #开启SQL语句执行日志信息 - show-sql: true - hibernate: - #配置为自动创建 - ddl-auto: create -``` - -`ddl-auto`属性用于设置自动表定义,可以实现自动在数据库中为我们创建一个表,表的结构会根据我们定义的实体类决定,它有4种 - -* create 启动时删数据库中的表,然后创建,退出时不删除数据表 -* create-drop 启动时删数据库中的表,然后创建,退出时删除数据表 如果表不存在报错 -* update 如果启动时表格式不一致则更新表,原有数据保留 -* validate 项目启动表结构进行校验 如果不一致则报错 - -我们可以在日志中发现,在启动时执行了如下SQL语句: - -``` -Hibernate: create table users (id integer not null auto_increment, password varchar(255), username varchar(255), primary key (id)) engine=InnoDB -``` - -而我们的数据库中对应的表已经创建好了。 - -我们接着来看如何访问我们的表,我们需要创建一个Repository实现类: - -```java -@Repository -public interface AccountRepository extends JpaRepository { - -} -``` - -注意JpaRepository有两个泛型,前者是具体操作的对象实体,也就是对应的表,后者是ID的类型,接口中已经定义了比较常用的数据库操作。编写接口继承即可,我们可以直接注入此接口获得实现类: - -```java -@SpringBootTest -class JpaTestApplicationTests { - - @Resource - AccountRepository repository; - - @Test - void contextLoads() { - //直接根据ID查找 - repository.findById(1).ifPresent(System.out::println); - } - -} -``` - -运行后,成功得到查询结果。我们接着来测试增删操作: - -```java -@Test -void addAccount(){ - Account account = new Account(); - account.setUsername("Admin"); - account.setPassword("123456"); - account = repository.save(account); //返回的结果会包含自动生成的主键值 - System.out.println("插入时,自动生成的主键ID为:"+account.getId()); -} -``` - -```java -@Test -void deleteAccount(){ - repository.deleteById(2); //根据ID删除对应记录 -} -``` - -```java -@Test -void pageAccount() { - repository.findAll(PageRequest.of(0, 1)).forEach(System.out::println); //直接分页 -} -``` - -我们发现,使用了JPA之后,整个项目的代码中没有出现任何的SQL语句,可以说是非常方便了,JPA依靠我们提供的注解信息自动完成了所有信息的映射和关联。 - -相比Mybatis,JPA几乎就是一个全自动的ORM框架,而Mybatis则顶多算是半自动ORM框架。 - -### 方法名称拼接自定义SQL - -虽然接口预置的方法使用起来非常方便,但是如果我们需要进行条件查询等操作或是一些判断,就需要自定义一些方法来实现,同样的,我们不需要编写SQL语句,而是通过方法名称的拼接来实现条件判断,这里列出了所有支持的条件判断名称: - -| `Distinct` | `findDistinctByLastnameAndFirstname` | `select distinct … where x.lastname = ?1 and x.firstname = ?2` | -| ---------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | -| `And` | `findByLastnameAndFirstname` | `… where x.lastname = ?1 and x.firstname = ?2` | -| `Or` | `findByLastnameOrFirstname` | `… where x.lastname = ?1 or x.firstname = ?2` | -| `Is`,`Equals` | `findByFirstname`,`findByFirstnameIs`,`findByFirstnameEquals` | `… where x.firstname = ?1` | -| `Between` | `findByStartDateBetween` | `… where x.startDate between ?1 and ?2` | -| `LessThan` | `findByAgeLessThan` | `… where x.age < ?1` | -| `LessThanEqual` | `findByAgeLessThanEqual` | `… where x.age <= ?1` | -| `GreaterThan` | `findByAgeGreaterThan` | `… where x.age > ?1` | -| `GreaterThanEqual` | `findByAgeGreaterThanEqual` | `… where x.age >= ?1` | -| `After` | `findByStartDateAfter` | `… where x.startDate > ?1` | -| `Before` | `findByStartDateBefore` | `… where x.startDate < ?1` | -| `IsNull`,`Null` | `findByAge(Is)Null` | `… where x.age is null` | -| `IsNotNull`,`NotNull` | `findByAge(Is)NotNull` | `… where x.age not null` | -| `Like` | `findByFirstnameLike` | `… where x.firstname like ?1` | -| `NotLike` | `findByFirstnameNotLike` | `… where x.firstname not like ?1` | -| `StartingWith` | `findByFirstnameStartingWith` | `… where x.firstname like ?1`(参数与附加`%`绑定) | -| `EndingWith` | `findByFirstnameEndingWith` | `… where x.firstname like ?1`(参数与前缀`%`绑定) | -| `Containing` | `findByFirstnameContaining` | `… where x.firstname like ?1`(参数绑定以`%`包装) | -| `OrderBy` | `findByAgeOrderByLastnameDesc` | `… where x.age = ?1 order by x.lastname desc` | -| `Not` | `findByLastnameNot` | `… where x.lastname <> ?1` | -| `In` | `findByAgeIn(Collection ages)` | `… where x.age in ?1` | -| `NotIn` | `findByAgeNotIn(Collection ages)` | `… where x.age not in ?1` | -| `True` | `findByActiveTrue()` | `… where x.active = true` | -| `False` | `findByActiveFalse()` | `… where x.active = false` | -| `IgnoreCase` | `findByFirstnameIgnoreCase` | `… where UPPER(x.firstname) = UPPER(?1)` | - -比如我们想要实现根据用户名模糊匹配查找用户: - -```java -@Repository -public interface AccountRepository extends JpaRepository { - //按照表中的规则进行名称拼接,不用刻意去记,IDEA会有提示 - List findAllByUsernameLike(String str); -} -``` - -```java -@Test -void test() { - repository.findAllByUsernameLike("%T%").forEach(System.out::println); -} -``` - -又比如我们想同时根据用户名和ID一起查询: - -```java -@Repository -public interface AccountRepository extends JpaRepository { - - Account findByIdAndUsername(int id, String username); - //可以使用Optional类进行包装,Optional findByIdAndUsername(int id, String username); - - List findAllByUsernameLike(String str); -} -``` - -```java -@Test -void test() { - System.out.println(repository.findByIdAndUsername(1, "Test")); -} -``` - -比如我们想判断数据库中是否存在某个ID的用户: - -```java -@Repository -public interface AccountRepository extends JpaRepository { - - Account findByIdAndUsername(int id, String username); - - List findAllByUsernameLike(String str); - - boolean existsAccountById(int id); -} -``` - -```java -@Test -void test() { - System.out.println(repository.existsAccountByUsername("Test")); -} -``` - -注意自定义条件操作的方法名称一定要遵循规则,不然会出现异常: - -``` -Caused by: org.springframework.data.repository.query.QueryCreationException: Could not create query for public abstract ... -``` - -### 关联查询 - -在实际开发中,比较常见的场景还有关联查询,也就是我们会在表中添加一个外键字段,而此外键字段又指向了另一个表中的数据,当我们查询数据时,可能会需要将关联数据也一并获取,比如我们想要查询某个用户的详细信息,一般用户简略信息会单独存放一个表,而用户详细信息会单独存放在另一个表中。当然,除了用户详细信息之外,可能在某些电商平台还会有用户的购买记录、用户的购物车,交流社区中的用户帖子、用户评论等,这些都是需要根据用户信息进行关联查询的内容。 - -![img](https://img1.baidu.com/it/u=292198351,4011695440&fm=253&fmt=auto&app=138&f=JPEG?w=404&h=436) - -我们知道,在JPA中,每张表实际上就是一个实体类的映射,而表之间的关联关系,也可以看作对象之间的依赖关系,比如用户表中包含了用户详细信息的ID字段作为外键,那么实际上就是用户表实体中包括了用户详细信息实体对象: - -```java -@Data -@Entity -@Table(name = "users_detail") -public class AccountDetail { - - @Column(name = "id") - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Id - int id; - - @Column(name = "address") - String address; - - @Column(name = "email") - String email; - - @Column(name = "phone") - String phone; - - @Column(name = "real_name") - String realName; -} -``` - -而用户信息和用户详细信息之间形成了一对一的关系,那么这时我们就可以直接在类中指定这种关系: - -```java -@Data -@Entity -@Table(name = "users") -public class Account { - - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id") - @Id - int id; - - @Column(name = "username") - String username; - - @Column(name = "password") - String password; - - @JoinColumn(name = "detail_id") //指定存储外键的字段名称 - @OneToOne //声明为一对一关系 - AccountDetail detail; -} -``` - -在修改实体类信息后,我们发现在启动时也进行了更新,日志如下: - -``` -Hibernate: alter table users add column detail_id integer -Hibernate: create table users_detail (id integer not null auto_increment, address varchar(255), email varchar(255), phone varchar(255), real_name varchar(255), primary key (id)) engine=InnoDB -Hibernate: alter table users add constraint FK7gb021edkxf3mdv5bs75ni6jd foreign key (detail_id) references users_detail (id) -``` - -是不是感觉非常方便!都懒得去手动改表结构了。 - -接着我们往用户详细信息中添加一些数据,一会我们可以直接进行查询: - -```java -@Test -void pageAccount() { - repository.findById(1).ifPresent(System.out::println); -} -``` - -查询后,可以发现,得到如下结果: - -``` -Hibernate: select account0_.id as id1_0_0_, account0_.detail_id as detail_i4_0_0_, account0_.password as password2_0_0_, account0_.username as username3_0_0_, accountdet1_.id as id1_1_1_, accountdet1_.address as address2_1_1_, accountdet1_.email as email3_1_1_, accountdet1_.phone as phone4_1_1_, accountdet1_.real_name as real_nam5_1_1_ from users account0_ left outer join users_detail accountdet1_ on account0_.detail_id=accountdet1_.id where account0_.id=? -Account(id=1, username=Test, password=123456, detail=AccountDetail(id=1, address=四川省成都市青羊区, email=8371289@qq.com, phone=1234567890, realName=本伟)) -``` - -也就是,在建立关系之后,我们查询Account对象时,会自动将关联数据的结果也一并进行查询。 - -那要是我们只想要Account的数据,不想要用户详细信息数据怎么办呢?我希望在我要用的时候再获取详细信息,这样可以节省一些网络开销,我们可以设置懒加载,这样只有在需要时才会向数据库获取: - -```java -@JoinColumn(name = "detail_id") -@OneToOne(fetch = FetchType.LAZY) //将获取类型改为LAZY -AccountDetail detail; -``` - -接着我们测试一下: - -```java -@Transactional //懒加载属性需要在事务环境下获取,因为repository方法调用完后Session会立即关闭 -@Test -void pageAccount() { - repository.findById(1).ifPresent(account -> { - System.out.println(account.getUsername()); //获取用户名 - System.out.println(account.getDetail()); //获取详细信息(懒加载) - }); -} -``` - -接着我们来看看控制台输出了什么: - -``` -Hibernate: select account0_.id as id1_0_0_, account0_.detail_id as detail_i4_0_0_, account0_.password as password2_0_0_, account0_.username as username3_0_0_ from users account0_ where account0_.id=? -Test -Hibernate: select accountdet0_.id as id1_1_0_, accountdet0_.address as address2_1_0_, accountdet0_.email as email3_1_0_, accountdet0_.phone as phone4_1_0_, accountdet0_.real_name as real_nam5_1_0_ from users_detail accountdet0_ where accountdet0_.id=? -AccountDetail(id=1, address=四川省成都市青羊区, email=8371289@qq.com, phone=1234567890, realName=卢本) -``` - -可以看到,获取用户名之前,并没有去查询用户的详细信息,而是当我们获取详细信息时才进行查询并返回AccountDetail对象。 - -那么我们是否也可以在添加数据时,利用实体类之间的关联信息,一次性添加两张表的数据呢?可以,但是我们需要稍微修改一下级联关联操作设定: - -```java -@JoinColumn(name = "detail_id") -@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) //设置关联操作为ALL -AccountDetail detail; -``` - -* ALL:所有操作都进行关联操作 -* PERSIST:插入操作时才进行关联操作 -* REMOVE:删除操作时才进行关联操作 -* MERGE:修改操作时才进行关联操作 - -可以多个并存,接着我们来进行一下测试: - -```java -@Test -void addAccount(){ - Account account = new Account(); - account.setUsername("Nike"); - account.setPassword("123456"); - AccountDetail detail = new AccountDetail(); - detail.setAddress("重庆市渝中区解放碑"); - detail.setPhone("1234567890"); - detail.setEmail("73281937@qq.com"); - detail.setRealName("张三"); - account.setDetail(detail); - account = repository.save(account); - System.out.println("插入时,自动生成的主键ID为:"+account.getId()+",外键ID为:"+account.getDetail().getId()); -} -``` - -可以看到日志结果: - -``` -Hibernate: insert into users_detail (address, email, phone, real_name) values (?, ?, ?, ?) -Hibernate: insert into users (detail_id, password, username) values (?, ?, ?) -插入时,自动生成的主键ID为:6,外键ID为:3 -``` - -结束后会发现数据库中两张表都同时存在数据。 - -接着我们来看一对多关联,比如每个用户的成绩信息: - -```java -@JoinColumn(name = "uid") //注意这里的name指的是Score表中的uid字段对应的就是当前的主键,会将uid外键设置为当前的主键 -@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE) //在移除Account时,一并移除所有的成绩信息,依然使用懒加载 -List scoreList; -``` - -```java -@Data -@Entity -@Table(name = "users_score") //成绩表,注意只存成绩,不存学科信息,学科信息id做外键 -public class Score { - - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id") - @Id - int id; - - @OneToOne //一对一对应到学科上 - @JoinColumn(name = "cid") - Subject subject; - - @Column(name = "socre") - double score; - - @Column(name = "uid") - int uid; -} -``` - -```java -@Data -@Entity -@Table(name = "subjects") //学科信息表 -public class Subject { - - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "cid") - @Id - int cid; - - @Column(name = "name") - String name; - - @Column(name = "teacher") - String teacher; - - @Column(name = "time") - int time; -} -``` - -在数据库中填写相应数据,接着我们就可以查询用户的成绩信息了: - -```java -@Transactional -@Test -void test() { - repository.findById(1).ifPresent(account -> { - account.getScoreList().forEach(System.out::println); - }); -} -``` - -成功得到用户所有的成绩信息,包括得分和学科信息。 - -同样的,我们还可以将对应成绩中的教师信息单独分出一张表存储,并建立多对一的关系,因为多门课程可能由同一个老师教授(千万别搞晕了,一定要理清楚关联关系,同时也是考验你的基础扎不扎实): - -```java -@ManyToOne(fetch = FetchType.LAZY) -@JoinColumn(name = "tid") //存储教师ID的字段,和一对一是一样的,也会当前表中创个外键 -Teacher teacher; -``` - -接着就是教师实体类了: - -```java -@Data -@Entity -@Table(name = "teachers") -public class Teacher { - - @Column(name = "id") - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Id - int id; - - @Column(name = "name") - String name; - - @Column(name = "sex") - String sex; -} -``` - -最后我们再进行一下测试: - -```java -@Transactional -@Test -void test() { - repository.findById(3).ifPresent(account -> { - account.getScoreList().forEach(score -> { - System.out.println("课程名称:"+score.getSubject().getName()); - System.out.println("得分:"+score.getScore()); - System.out.println("任课教师:"+score.getSubject().getTeacher().getName()); - }); - }); -} -``` - -成功得到多对一的教师信息。 - -最后我们再来看最复杂的情况,现在我们一门课程可以由多个老师教授,而一个老师也可以教授多个课程,那么这种情况就是很明显的多对多场景,现在又该如何定义呢?我们可以像之前一样,插入一张中间表表示教授关系,这个表中专门存储哪个老师教哪个科目: - -```java -@ManyToMany(fetch = FetchType.LAZY) //多对多场景 -@JoinTable(name = "teach_relation", //多对多中间关联表 - joinColumns = @JoinColumn(name = "cid"), //当前实体主键在关联表中的字段名称 - inverseJoinColumns = @JoinColumn(name = "tid") //教师实体主键在关联表中的字段名称 -) -List teacher; -``` - -接着,JPA会自动创建一张中间表,并自动设置外键,我们就可以将多对多关联信息编写在其中了。 - -### JPQL自定义SQL语句 - -虽然SpringDataJPA能够简化大部分数据获取场景,但是难免会有一些特殊的场景,需要使用复杂查询才能够去完成,这时你又会发现,如果要实现,只能用回Mybatis了,因为我们需要自己手动编写SQL语句,过度依赖SpringDataJPA会使得SQL语句不可控。 - -使用JPA,我们也可以像Mybatis那样,直接编写SQL语句,不过它是JPQL语言,与原生SQL语句很类似,但是它是面向对象的,当然我们也可以编写原生SQL语句。 - -比如我们要更新用户表中指定ID用户的密码: - -```java -@Repository -public interface AccountRepository extends JpaRepository { - - @Transactional //DML操作需要事务环境,可以不在这里声明,但是调用时一定要处于事务环境下 - @Modifying //表示这是一个DML操作 - @Query("update Account set password = ?2 where id = ?1") //这里操作的是一个实体类对应的表,参数使用?代表,后面接第n个参数 - int updatePasswordById(int id, String newPassword); -} -``` - -```java -@Test -void updateAccount(){ - repository.updatePasswordById(1, "654321"); -} -``` - -现在我想使用原生SQL来实现根据用户名称修改密码: - -```java -@Transactional -@Modifying -@Query(value = "update users set password = :pwd where username = :name", nativeQuery = true) //使用原生SQL,和Mybatis一样,这里使用 :名称 表示参数,当然也可以继续用上面那种方式。 -int updatePasswordByUsername(@Param("name") String username, //我们可以使用@Param指定名称 - @Param("pwd") String newPassword); -``` - -```java -@Test -void updateAccount(){ - repository.updatePasswordByUsername("Admin", "654321"); -} -``` - -通过编写原生SQL,在一定程度上弥补了SQL不可控的问题。 - -虽然JPA能够为我们带来非常便捷的开发体验,但是正式因为太便捷了,保姆级的体验有时也会适得其反,可能项目开发到后期特别庞大时,就只能从底层SQL语句开始进行优化,而由于JPA尽可能地在屏蔽我们对SQL语句的编写,所以后期优化是个大问题,并且Hibernate相对于Mybatis来说,更加重量级。不过,在微服务的时代,单体项目一般不会太大,而JPA的劣势并没有太明显地体现出来。 - -有关Mybatis和JPA的对比,可以参考:https://blog.csdn.net/u010253246/article/details/105731204 - -*** - -## Extra. 前后端分离跨域处理 - -我们的项目已经处于前后端分离状态了,那么前后端分离状态和我们之前的状态有什么区别的呢? - -* **不分离:**前端页面看到的都是由后端控制,由后端渲染页面或重定向,后端需要控制前端的展示,前端与后端的耦合度很高。比如我们之前都是使用后端来执行重定向操作或是使用Thymeleaf来填充数据,而最终返回的是整个渲染好的页。 - -![img](https://img2018.cnblogs.com/blog/1394466/201809/1394466-20180916231510365-285933655.png) - -* **分离:**后端仅返回前端所需的数据,不再渲染HTML页面,不再控制前端的效果。至于前端用户看到什么效果,从后端请求的数据如何加载到前端中,都由前端通过JS等进行动态数据填充和渲染。这样后端只返回JSON数据,前端处理JSON数据并展示,这样前后端的职责就非常明确了。 - -![img](https://img2018.cnblogs.com/blog/1394466/201809/1394466-20180916231716242-1862208927.png) - -实现前后端分离有两种方案,一种是直接放入SpringBoot的资源文件夹下,但是这样实际上还是在依靠SpringBoot内嵌的Tomcat服务器进行页面和静态资源的发送,我们现在就是这种方案。 - -另一种方案就是直接将所有的页面和静态资源单独放到代理服务器上(如Nginx),这样我们后端服务器就不必再处理静态资源和页面了,专心返回数据即可,而前端页面就需要访问另一个服务器来获取,虽然逻辑和明确,但是这样会出现跨域问题,实际上就是我们之前所说的跨站请求伪造,为了防止这种不安全的行为发生,所以对异步请求会进行一定的限制。 - -这里,我们将前端页面和后端页面直接分离进行测试,在登陆时得到如下错误: - -``` -Access to XMLHttpRequest at 'http://localhost:8080/api/auth/login' from origin 'http://localhost:63342' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. -``` - -可以很清楚地看到,在Ajax发送异步请求时,我们的请求被阻止,原因是在响应头中没有包含`Access-Control-Allow-Origin`,也就表示,如果服务端允许跨域请求,那么会在响应头中添加一个`Access-Control-Allow-Origin`字段,如果不允许跨域,就像现在这样。那么,什么才算是跨域呢: - -1. 请求协议`如http、https`不同 -2. 请求的地址/域名不同 -3. 端口不同 - -因为我们现在相当于前端页面访问的是静态资源服务器,而后端数据是由我们的SpringBoot项目提供,它们是两个不同的服务器,所以在垮服务器请求资源时,会被判断为存在安全风险。 - -但是现在,由于我们前后端是分离状态,我们希望的是能够实现跨域请求,这时我们就需要添加一个过滤器来处理跨域问题: - -```java -@Bean -public CorsFilter corsFilter() { - //创建CorsConfiguration对象后添加配置 - CorsConfiguration config = new CorsConfiguration(); - //设置放行哪些原始域,这里直接设置为所有 - config.addAllowedOriginPattern("*"); - //你可以单独设置放行哪些原始域 config.addAllowedOrigin("http://localhost:2222"); - //放行哪些原始请求头部信息 - config.addAllowedHeader("*"); - //放行哪些请求方式,*代表所有 - config.addAllowedMethod("*"); - //是否允许发送Cookie,必须要开启,因为我们的JSESSIONID需要在Cookie中携带 - config.setAllowCredentials(true); - //映射路径 - UrlBasedCorsConfigurationSource corsConfigurationSource = new UrlBasedCorsConfigurationSource(); - corsConfigurationSource.registerCorsConfiguration("/**", config); - //返回CorsFilter - return new CorsFilter(corsConfigurationSource); -} -``` - -这样,我们的SpringBoot项目就支持跨域访问了,接着我们再来尝试进行登陆,可以发现已经能够正常访问了,并且响应头中包含了以下信息: - -``` -Vary: Access-Control-Request-Method -Vary: Access-Control-Request-Headers -Access-Control-Allow-Origin: http://localhost:63342 -Access-Control-Expose-Headers: * -Access-Control-Allow-Credentials: true -``` - -可以看到我们当前访问的原始域已经被放行了。 - -但是还有一个问题,我们的Ajax请求中没有携带Cookie信息(这个按理说属于前端知识了)这里我们稍微改一下,不然我们的请求无法确认身份: - -```js -function get(url, success){ - $.ajax({ - type: "get", - url: url, - async: true, - dataType: 'json', - xhrFields: { - withCredentials: true - }, - success: success - }); -} - -function post(url, data, success){ - $.ajax({ - type: "post", - url: url, - async: true, - data: data, - dataType: 'json', - xhrFields: { - withCredentials: true - }, - success: success - }); -} -``` - -添加两个封装好的方法,并且将`withCredentials`开启,这样在发送异步请求时,就会携带Cookie信息了。 - - 在学习完成Linux之后,我们会讲解如何在Linux服务器上部署Nginx反向代理服务器。 - -*** - -## 接口管理:Swagger - -在前后端分离项目中,前端人员需要知道我们后端会提供什么数据,根据后端提供的数据来进行前端页面渲染(在之前我们也演示过)这个时候,我们就需要编写一个API文档,以便前端人员随时查阅。 - -但是这样的一个文档,我们也不可能单独写一个项目去进行维护,并且随着我们的后端项目不断更新,文档也需要跟随更新,这显然是很麻烦的一件事情,那么有没有一种比较好的解决方案呢? - -当然有,那就是丝袜哥:Swagger - -### 走进Swagger - -Swagger的主要功能如下: - -- 支持 API 自动生成同步的在线文档:使用 Swagger 后可以直接通过代码生成文档,不再需要自己手动编写接口文档了,对程序员来说非常方便,可以节约写文档的时间去学习新技术。 -- 提供 Web 页面在线测试 API:光有文档还不够,Swagger 生成的文档还支持在线测试。参数和格式都定好了,直接在界面上输入参数对应的值即可在线测试接口。 - -结合Spring框架(Spring-fox),Swagger可以很轻松地利用注解以及扫描机制,来快速生成在线文档,以实现当我们项目启动之后,前端开发人员就可以打开Swagger提供的前端页面,查看和测试接口。依赖如下: - -```xml - - io.springfox - springfox-boot-starter - 3.0.0 - -``` - -SpringBoot 2.6以上版本修改了路径匹配规则,但是Swagger3还不支持,这里换回之前的,不然启动直接报错: - -```yaml -spring: - mvc: - pathmatch: - matching-strategy: ant_path_matcher -``` - -项目启动后,我们可以直接打开:http://localhost:8080/swagger-ui/index.html,这个页面(要是觉得丑,UI是可以换的,支持第三方)会显示所有的API文档,包括接口的路径、支持的方法、接口的描述等,并且我们可以直接对API接口进行测试,非常方便。 - -我们可以创建一个配置类去配置页面的相关信息: - -```java -@Configuration -public class SwaggerConfiguration { - - @Bean - public Docket docket() { - return new Docket(DocumentationType.OAS_30).apiInfo( - new ApiInfoBuilder() - .contact(new Contact("你的名字", "https://www.bilibili.com", "javastudy111*@163.com")) - .title("图书管理系统 - 在线API接口文档") - .build() - ); - } -} -``` - -### 接口信息配置 - -虽然Swagger的UI界面已经可以很好地展示后端提供的接口信息了,但是非常的混乱,我们来看看如何配置接口的一些描述信息。 - -首先我们的页面中完全不需要显示ErrorController相关的API,所以我们配置一下选择哪些Controller才会生成API信息: - -```java -@Bean -public Docket docket() { - ApiInfo info = new ApiInfoBuilder() - .contact(new Contact("你的名字", "https://www.bilibili.com", "javastudy111@163.com")) - .title("图书管理系统 - 在线API接口文档") - .description("这是一个图书管理系统的后端API文档,欢迎前端人员查阅!") - .build(); - return new Docket(DocumentationType.OAS_30) - .apiInfo(info) - .select() //对项目中的所有API接口进行选择 - .apis(RequestHandlerSelectors.basePackage("com.example.controller")) - .build(); -} -``` - -接着我们来看看如何为一个Controller编写API描述信息: - -```java -@Api(tags = "账户验证接口", description = "包括用户登录、注册、验证码请求等操作。") -@RestController -@RequestMapping("/api/auth") -public class AuthApiController { -``` - -我们可以直接在类名称上面添加`@Api`注解,并填写相关信息,来为当前的Controller设置描述信息。 - -接着我们可以为所有的请求映射配置描述信息: - -```java -@ApiResponses({ - @ApiResponse(code = 200, message = "邮件发送成功"), - @ApiResponse(code = 500, message = "邮件发送失败") //不同返回状态码描述 -}) -@ApiOperation("请求邮件验证码") //接口描述 -@GetMapping("/verify-code") -public RestBean verifyCode(@ApiParam("邮箱地址") //请求参数的描述 - @RequestParam("email") String email){ -``` - -```java -@ApiIgnore //忽略此请求映射 -@PostMapping("/login-success") -public RestBean loginSuccess(){ - return new RestBean<>(200, "登陆成功"); -} -``` - -我们也可以为实体类配置相关的描述信息: - -```java -@Data -@ApiModel(description = "响应实体封装类") -@AllArgsConstructor -public class RestBean { - - @ApiModelProperty("状态码") - int code; - @ApiModelProperty("状态码描述") - String reason; - @ApiModelProperty("数据实体") - T data; - - public RestBean(int code, String reason) { - this.code = code; - this.reason = reason; - } -} -``` - -这样,我们就可以在文档中查看实体类简介以及各个属性的介绍了。 - -最后我们再配置一下多环境: - -```xml - - - dev - - true - - - dev - - - - prod - - false - - - prod - - - -``` - -```xml - - - src/main/resources - - application*.yaml - - - - src/main/resources - true - - application.yaml - application-${environment}.yaml - - - -``` - -首先在Maven中添加两个环境,接着我们配置一下不同环境的配置文件: - -```yaml - jpa: - show-sql: false - hibernate: - ddl-auto: update -springfox: - documentation: - enabled: false -``` - -在生产环境下,我们选择不开启Swagger文档以及JPA的数据库操作日志,这样我们就可以根据情况选择两套环境了。 diff --git a/青空笔记/SpringCloud笔记/SpringCloud笔记(一).md b/青空笔记/SpringCloud笔记/SpringCloud笔记(一).md deleted file mode 100644 index fd34b62..0000000 --- a/青空笔记/SpringCloud笔记/SpringCloud笔记(一).md +++ /dev/null @@ -1,1612 +0,0 @@ -![image-20220317173734208](https://tva1.sinaimg.cn/large/e6c9d24ely1h0d0lfurfij21o40d275h.jpg) - -# 微服务基础 - -**注意:**此阶段学习推荐的电脑配置,至少配备4核心CPU(主频3.0Ghz以上)+16GB内存,否则卡到你怀疑人生。 - -前面我们讲解了SpringBoot框架,通过使用SpringBoot框架,我们的项目开发速度可以说是得到了质的提升。同时,我们对于项目的维护和理解,也会更加的轻松。可见,SpringBoot为我们的开发带来了巨大便捷。而这一部分,我们将基于SpringBoot,继续深入到企业实际场景,探讨微服务架构下的SpringCloud。这个部分我们会更加注重于架构设计上的讲解,弱化实现原理方面的研究。 - -## 传统项目转型 - -要说近几年最火热的话题,那还得是微服务,那么什么是微服务呢? - -我们可以先从技术的演变开始看起,在我们学习JavaWeb之后,一般的网站开发模式为Servlet+JSP,但是实际上我们在学习了SSM之后,会发现这种模式已经远远落后了,第一,一个公司不可能去招那么多同时会前端+后端的开发人员,就算招到,也并不一定能保证两个方面都比较擅长,相比前后端分开学习的开发人员,显然后者的学习成本更低,专注度更高。因此前后端分离成为了一种新的趋势。通过使用SpringBoot,我们几乎可以很快速地开发一个高性能的单体应用,只需要启动一个服务端,我们整个项目就开始运行了,各项功能融于一体,开发起来也更加轻松。 - -但是随着我们项目的不断扩大,单体应用似乎显得有点乏力了。 - -随着越来越多的功能不断地加入到一个SpringBoot项目中,随着接口不断增加,整个系统就要在同一时间内响应更多类型的请求,显然,这种扩展方式是不可能无限使用下去的,总有一天,这个SpringBoot项目会庞大到运行缓慢。并且所有的功能如果都集成在单端上,那么所有的请求都会全部汇集到一台服务器上,对此服务器造成巨大压力。 - -可以试想一下,如果我们的电脑已经升级到i9-12900K,但是依然在运行项目的时候缓慢,无法同一时间响应成千上万的请求,那么这个问题就已经不是单纯升级机器配置可以解决的了。 - -![image-20220320174622739](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ghpknvdvj21zg0go78r.jpg) - -传统单体架构应用随着项目规模的扩大,实际上会暴露越来越多的问题,尤其是一台服务器无法承受庞大的单体应用部署,并且单体应用的维护也会越来越困难,我们得寻找一种新的开发架构来解决这些问题了。 - -> In short, the microservice architectural style is an approach to developing a single application as a suite of small services, each running in its own process and communicating with lightweight mechanisms, often an HTTP resource API. These services are built around business capabilities and independently deployable by fully automated deployment machinery. There is a bare minimum of centralized management of these services, which may be written in different programming languages and use different data storage technologies. - -Martin Fowler在2014年提出了“微服务”架构,它是一种全新的架构风格。 - -* 微服务把一个庞大的单体应用拆分为一个个的小型服务,比如我们原来的图书管理项目中,有登录、注册、添加、删除、搜索等功能,那么我们可以将这些功能单独做成一个个小型的SpringBoot项目,独立运行。 -* 每个小型的微服务,都可以独立部署和升级,这样,就算整个系统崩溃,那么也只会影响一个服务的运行。 -* 微服务之间使用HTTP进行数据交互,不再是单体应用内部交互了,虽然这样会显得更麻烦,但是带来的好处也是很直接的,甚至能突破语言限制,使用不同的编程语言进行微服务开发,只需要使用HTTP进行数据交互即可。 -* 我们可以同时购买多台主机来分别部署这些微服务,这样,单机的压力就被分散到多台机器,并且每台机器的配置不一定需要太高,这样就能节省大量的成本,同时安全性也得到很大的保证。 -* 甚至同一个微服务可以同时存在多个,这样当其中一个服务器出现问题时,其他服务器也在运行同样的微服务,这样就可以保证一个微服务的高可用。 - -![image-20220322090754438](https://tva1.sinaimg.cn/large/e6c9d24egy1h0idyqgp12j21m00im0wa.jpg) - -当然,这里只是简单的演示一下微服务架构,实际开发中肯定是比这个复杂得多的。 - -可见,采用微服务架构,更加能够应对当今时代下的种种考验,传统项目的开发模式,需要进行架构上的升级。 - -## 走进SpringCloud - -前面我们介绍了微服务架构的优点,那么同样的,这些优点的背后也存在着诸多的问题: - -* 要实现微服务并不是说只需要简单地将项目进行拆分,我们还需要考虑对各个微服务进行管理、监控等,这样我们才能够及时地寻找和排查问题。因此微服务往往需要的是一整套解决方案,包括服务注册和发现、容灾处理、负载均衡、配置管理等。 -* 它不像单体架构那种方便维护,由于部署在多个服务器,我们不得不去保证各个微服务能够稳定运行,在管理难度上肯定是高于传统单体应用的。 -* 在分布式的环境下,单体应用的某些功能可能会变得比较麻烦,比如分布式事务。 - -所以,为了更好地解决这些问题,SpringCloud正式登场。 - -SpringCloud是Spring提供的一套分布式解决方案,集合了一些大型互联网公司的开源产品,包括诸多组件,共同组成SpringCloud框架。并且,它利用Spring Boot的开发便利性巧妙地简化了分布式系统基础设施的开发,如服务发现注册、配置中心、消息总线、负载均衡、熔断机制、数据监控等,都可以用Spring Boot的开发风格做到一键启动和部署。 - -由于中小型公司没有独立开发自己的分布式基础设施的能力,使用SpringCloud解决方案能够以最低的成本应对当前时代的业务发展。 - -![image-20220322102706256](https://tva1.sinaimg.cn/large/e6c9d24egy1h0ig93rk52j21ss0nytbj.jpg) - -可以看到,SpringCloud整体架构的亮点是非常明显的,分布式架构下的各个场景,都有对应的组件来处理,比如基于Netflix(奈飞)的开源分布式解决方案提供的组件: - -- Eureka - 实现服务治理(服务注册与发现),我们可以对所有的微服务进行集中管理,包括他们的运行状态、信息等。 -- Ribbon - 为服务之间相互调用提供负载均衡算法(现在被SpringCloudLoadBalancer取代) -- Hystrix - 断路器,保护系统,控制故障范围。暂时可以跟家里电闸的保险丝类比,当触电危险发生时能够防止进一步的发展。 -- Zuul - api网关,路由,负载均衡等多种作用,就像我们的路由器,可能有很多个设备都连接了路由器,但是数据包要转发给谁则是由路由器在进行(已经被SpringCloudGateway取代) -- Config - 配置管理,可以实现配置文件集中管理 - -当然,这里只是进行简单的了解即可,实际上微服务的玩法非常多,我们后面的学习中将会逐步进行探索。 - -那么首先,我们就从注册中心开始说起。 - -*** - -## Eureka 注册中心 - -官方文档:https://docs.spring.io/spring-cloud-netflix/docs/current/reference/html/ - -**小贴士:**各位小伙伴在学习的过程中觉得有什么疑惑的可以直接查阅官方文档,我们会在每一个技术开始之前贴上官方文档的地址,方便各位进行查阅,同时在我们的课程中并不一定会完完整整地讲完整个框架的内容,有关详细的功能和使用方法文档中也是写的非常清楚的,感兴趣的可以深入学习哦。 - -### 微服务项目结构 - -现在我们重新设计一下之前的图书管理系统项目,将原有的大型(也许 项目进行拆分,注意项目拆分一定要尽可能保证单一职责,相同的业务不要在多个微服务中重复出现,如果出现需要借助其他业务完成的服务,那么可以使用服务之间相互调用的形式来实现(之后会介绍): - -* 登录验证服务:用于处理用户注册、登录、密码重置等,反正就是一切与账户相关的内容,包括用户信息获取等。 -* 图书管理服务:用于进行图书添加、删除、更新等操作,图书管理相关的服务,包括图书的存储等和信息获取。 -* 图书借阅服务:交互性比较强的服务,需要和登陆验证服务和图书管理服务进行交互。 - -那么既然要将单体应用拆分为多个小型服务,我们就需要重新设计一下整个项目目录结构,这里我们就创建多个子项目,每一个子项目都是一个服务,这样由父项目统一管理依赖,就无需每个子项目都去单独管理依赖了,也更方便一点。 - -我们首先创建一个普通的SpringBoot项目: - -![image-20220323105531867](https://tva1.sinaimg.cn/large/e6c9d24ely1h0jmp0yg1lj21zh0u0q8t.jpg) - -然后不需要勾选任何依赖,直接创建即可,项目创建完成并初始化后,我们删除父工程的无用文件,只保留必要文件,像下面这样: - -![image-20220323105859454](https://tva1.sinaimg.cn/large/e6c9d24ely1h0jmsk8wcrj21rw0lojt4.jpg) - -接着我们就可以按照我们划分的服务,进行子工程创建了,创建一个新的Maven项目,注意父项目要指定为我们一开始创建的的项目,子项目命名随意: - -![image-20220323110133466](https://tva1.sinaimg.cn/large/e6c9d24ely1h0jmv8tewij21rc0f640q.jpg) - -子项目创建好之后,接着我们在子项目中创建SpringBoot的启动主类: - -![image-20220323110756722](https://tva1.sinaimg.cn/large/e6c9d24ely1h0jn1w0ru4j21ra0kedkk.jpg) - -接着我们点击运行,即可启动子项目了,实际上这个子项目就一个最简单的SpringBoot web项目,注意启动之后最下方有弹窗,我们点击"使用 服务",这样我们就可以实时查看当前整个大项目中有哪些微服务了: - -![image-20220323110917997](https://tva1.sinaimg.cn/large/e6c9d24ely1h0jn3ah2t1j21by078t9h.jpg) - -![image-20220323111056940](https://tva1.sinaimg.cn/large/e6c9d24ely1h0jn50f7aqj22po0kygus.jpg) - -接着我们以同样的方法,创建其他的子项目,注意我们最好将其他子项目的端口设置得不一样,不然会导致端口占用,我们分别为它们创建`application.yml`文件: - -![image-20220323111733605](https://tva1.sinaimg.cn/large/e6c9d24ely1h0jnbw1wi8j22ci0i2778.jpg) - -接着我们来尝试启动一下这三个服务,正常情况下都是可以直接启动的: - -![image-20220323111849846](https://tva1.sinaimg.cn/large/e6c9d24ely1h0jnd7uz99j22q40ks7dw.jpg) - -可以看到它们分别运行在不同的端口上,这样,就方便不同的程序员编写不同的服务了,提交当前项目代码时的冲突率也会降低。 - -接着我们来创建一下数据库,这里还是老样子,创建三个表即可,当然实际上每个微服务单独使用一个数据库服务器也是可以的,因为按照单一职责服务只会操作自己对应的表,这里UP主比较穷,就只用一个数据库演示了: - -![image-20220323112340995](https://tva1.sinaimg.cn/large/e6c9d24ely1h0jni9lpt1j214w09w3zd.jpg) - -![image-20220323112616538](https://tva1.sinaimg.cn/large/e6c9d24ely1h0jnkydk2mj214y090t9h.jpg) - -![image-20220323112842758](https://tva1.sinaimg.cn/large/e6c9d24ely1h0jnnhmt9vj214a072t99.jpg) - -![image-20220323112750936](https://tva1.sinaimg.cn/large/e6c9d24ely1h0jnmlc81qj214806a0th.jpg) - -![image-20220323112825430](https://tva1.sinaimg.cn/large/e6c9d24ely1h0jnn6v8jrj214o04y3yw.jpg) - -创建好之后,结果如下,一共三张表,各位可以自行添加一些数据到里面,这就不贴出来了: - -![image-20220323112922396](https://tva1.sinaimg.cn/large/e6c9d24ely1h0jno6ge9hj21go07mab1.jpg) - -如果各位嫌麻烦的话可以下载`.sql`文件自行导入。 - -接着我们来稍微写一点业务,比如用户信息查询业务,我们先把数据库相关的依赖进行导入,这里依然使用Mybatis框架,首先在父项目中添加MySQL驱动和Lombok依赖: - -```xml - - mysql - mysql-connector-java - - - - org.projectlombok - lombok - -``` - -由于不是所有的子项目都需要用到Mybatis,我们在父项目中只进行版本管理即可: - -```xml - - - - org.mybatis.spring.boot - mybatis-spring-boot-starter - 2.2.0 - - - -``` - -接着我们就可以在用户服务子项目中添加此依赖了: - -```xml - - - org.mybatis.spring.boot - mybatis-spring-boot-starter - - -``` - -接着添加数据源信息(UP用到是阿里云的MySQL云数据库,各位注意修改一下数据库地址): - -```yaml -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://cloudstudy.mysql.cn-chengdu.rds.aliyuncs.com:3306/cloudstudy - username: test - password: 123456 -``` - -接着我们来写用户查询相关的业务: - -```java -@Data -public class User { - int uid; - String name; - String sex; -} -``` - -```java -@Mapper -public interface UserMapper { - @Select("select * from DB_USER where uid = #{uid}") - User getUserById(int uid); -} -``` - -```java -public interface UserService { - User getUserById(int uid); -} -``` - -```java -@Service -public class UserServiceImpl implements UserService { - - @Resource - UserMapper mapper; - - @Override - public User getUserById(int uid) { - return mapper.getUserById(uid); - } -} -``` - -```java -@RestController -public class UserController { - - @Resource - UserService service; - - //这里以RESTFul风格为例 - @RequestMapping("/user/{uid}") - public User findUserById(@PathVariable("uid") int uid){ - return service.getUserById(uid); - } -} -``` - -现在我们访问即可拿到数据: - -![image-20220323133820304](https://tva1.sinaimg.cn/large/e6c9d24egy1h0jredurecj213m06s74p.jpg) - -同样的方式,我们完成一下图书查询业务,注意现在是在图书管理微服务中编写(别忘了导入Mybatis依赖以及配置数据源): - -```java -@Data -public class Book { - int bid; - String title; - String desc; -} -``` - -```java -@Mapper -public interface BookMapper { - - @Select("select * from DB_BOOK where bid = #{bid}") - Book getBookById(int bid); -} -``` - -```java -public interface BookService { - Book getBookById(int bid); -} -``` - -```java -@Service -public class BookServiceImpl implements BookService { - - @Resource - BookMapper mapper; - - @Override - public Book getBookById(int bid) { - return mapper.getBookById(bid); - } -} -``` - -```java -@RestController -public class BookController { - - @Resource - BookService service; - - @RequestMapping("/book/{bid}") - Book findBookById(@PathVariable("bid") int bid){ - return service.getBookById(bid); - } -} -``` - -同样进行一下测试: - -![image-20220323134742618](https://tva1.sinaimg.cn/large/e6c9d24egy1h0jro4g42vj215006sdgg.jpg) - -这样,我们一个完整项目的就拆分成了多个微服务,不同微服务之间是独立进行开发和部署的。 - -### 服务间调用 - -前面我们完成了用户信息查询和图书信息查询,现在我们来接着完成借阅服务。 - -借阅服务是一个关联性比较强的服务,它不仅仅需要查询借阅信息,同时可能还需要获取借阅信息下的详细信息,比如具体那个用户借阅了哪本书,并且用户和书籍的详情也需要同时出现,那么这种情况下,我们就需要去访问除了借阅表以外的用户表和图书表。 - -![image-20220323140053749](https://tva1.sinaimg.cn/large/e6c9d24egy1h0js1udjp6j21oa0aw40q.jpg) - -但是这显然是违反我们之前所说的单一职责的,相同的业务功能不应该重复出现,但是现在由需要在此服务中查询用户的信息和图书信息,那怎么办呢?我们可以让一个服务去调用另一个服务来获取信息。 - -![image-20220323140322502](https://tva1.sinaimg.cn/large/e6c9d24egy1h0js4et40uj21q20agq52.jpg) - -这样,图书管理微服务和用户管理微服务相对于借阅记录,就形成了一个生产者和消费者的关系,前者是生产者,后者便是消费者。 - -现在我们先将借阅关联信息查询完善了: - -```java -@Data -public class Borrow { - int id; - int uid; - int bid; -} -``` - -```java -@Mapper -public interface BorrowMapper { - @Select("select * from DB_BORROW where uid = #{uid}") - List getBorrowsByUid(int uid); - - @Select("select * from DB_BORROW where bid = #{bid}") - List getBorrowsByBid(int bid); - - @Select("select * from DB_BORROW where bid = #{bid} and uid = #{uid}") - Borrow getBorrow(int uid, int bid); -} -``` - -现在有一个需求,需要查询用户的借阅详细信息,也就是说需要查询某个用户具体借了那些书,并且需要此用户的信息和所有已借阅的书籍信息一起返回,那么我们先来设计一下返回实体: - -```java -@Data -@AllArgsConstructor -public class UserBorrowDetail { - User user; - List bookList; -} -``` - -但是有一个问题,我们发现User和Book实体实际上是在另外两个微服务中定义的,相当于当前项目并没有定义这些实体类,那么怎么解决呢? - -因此,我们可以将所有服务需要用到的实体类单独放入另一个一个项目中,然后让这些项目引用集中存放实体类的那个项目,这样就可以保证每个微服务的实体类信息都可以共用了: - -![image-20220323141919836](https://tva1.sinaimg.cn/large/e6c9d24egy1h0jsl18z1fj221e0bi0w1.jpg) - -然后只需要在对应的类中引用此项目作为依赖即可: - -```xml - - com.example - commons - 0.0.1-SNAPSHOT - -``` - -之后新的公共实体类都可以在`commons`项目中进行定义了,现在我们接着来完成刚刚的需求,先定义接口: - -```java -public interface BorrowService { - - UserBorrowDetail getUserBorrowDetailByUid(int uid); -} -``` - -```java -@Service -public class BorrowServiceImpl implements BorrowService{ - - @Resource - BorrowMapper mapper; - - @Override - public UserBorrowDetail getUserBorrowDetailByUid(int uid) { - List borrow = mapper.getBorrowsByUid(uid); - //那么问题来了,现在拿到借阅关联信息了,怎么调用其他服务获取信息呢? - } -} -``` - -需要进行服务远程调用我们需要用到`RestTemplate`来进行: - -```java -@Service -public class BorrowServiceImpl implements BorrowService{ - - @Resource - BorrowMapper mapper; - - @Override - public UserBorrowDetail getUserBorrowDetailByUid(int uid) { - List borrow = mapper.getBorrowsByUid(uid); - //RestTemplate支持多种方式的远程调用 - RestTemplate template = new RestTemplate(); - //这里通过调用getForObject来请求其他服务,并将结果自动进行封装 - //获取User信息 - User user = template.getForObject("http://localhost:8082/user/"+uid, User.class); - //获取每一本书的详细信息 - List bookList = borrow - .stream() - .map(b -> template.getForObject("http://localhost:8080/book/"+b.getBid(), Book.class)) - .collect(Collectors.toList()); - return new UserBorrowDetail(user, bookList); - } -} -``` - -现在我们再最后完善一下Controller: - -```java -@RestController -public class BorrowController { - - @Resource - BorrowService service; - - @RequestMapping("/borrow/{uid}") - UserBorrowDetail findUserBorrows(@PathVariable("uid") int uid){ - return service.getUserBorrowDetailByUid(uid); - } -} -``` - -在数据库中添加一点借阅信息,测试看看能不能正常获取(注意一定要保证三个服务都处于开启状态,否则远程调用会失败): - -![image-20220323143753567](https://tva1.sinaimg.cn/large/e6c9d24egy1h0jt4ccuywj226i06wdh5.jpg) - -可以看到,结果正常,没有问题,远程调用成功。 - -这样,一个简易的图书管理系统的分布式项目就搭建完成了,**这里记得把整个项目压缩打包备份一下**,下一章学习SpringCloud Alibaba也需要进行配置。 - -### 服务注册与发现 - -前面我们了解了如何对单体应用进行拆分,并且也学习了如何进行服务之间的相互调用,但是存在一个问题,就是虽然服务拆分完成,但是没有一个比较合理的管理机制,如果单纯只是这样编写,在部署和维护起来,肯定是很麻烦的。可以想象一下,如果某一天这些微服务的端口或是地址大规模地发生改变,我们就不得不将服务之间的调用路径大规模的同步进行修改,这是多么可怕的事情。我们需要削弱这种服务之间的强关联性,因此我们需要一个集中管理微服务的平台,这时就要借助我们这一部分的主角了。 - -Eureka能够自动注册并发现微服务,然后对服务的状态、信息进行集中管理,这样当我们需要获取其他服务的信息时,我们只需要向Eureka进行查询就可以了。 - -![image-20220323145051821](https://tva1.sinaimg.cn/large/e6c9d24egy1h0jthur4u0j21lu0dytam.jpg) - -像这样的话,服务之间的强关联性就会被进一步削弱。 - -那么现在我们就来搭建一个Eureka服务器,只需要创建一个新的Maven项目即可,然后我们需要在父工程中添加一下SpringCloud的依赖,这里选用`2021.0.1`版本(Spring Cloud 最新的版本命名方式变更了,现在是 ***YEAR.x*** 这种命名方式,具体可以在官网查看:https://spring.io/projects/spring-cloud#learn): - -```xml - - org.springframework.cloud - spring-cloud-dependencies - 2021.0.1 - pom - import - -``` - -接着我们为新创建的项目添加依赖: - -```xml - - - org.springframework.cloud - spring-cloud-starter-netflix-eureka-server - - -``` - -下载内容有点多,首次导入请耐心等待一下。 - -接着我们来创建主类,还是一样的操作: - -```java -@EnableEurekaServer -@SpringBootApplication -public class EurekaServerApplication { - - public static void main(String[] args) { - SpringApplication.run(EurekaServerApplication.class, args); - } -} -``` - -别着急启动!!!接着我们需要修改一下配置文件: - -```yaml -server: - port: 8888 -eureka: - # 开启之前需要修改一下客户端设置(虽然是服务端 - client: - # 由于我们是作为服务端角色,所以不需要获取服务端,改为false,默认为true - fetch-registry: false - # 暂时不需要将自己也注册到Eureka - register-with-eureka: false - # 将eureka服务端指向自己 - service-url: - defaultZone: http://localhost:8888/eureka -``` - -好了,现在差不多可以启动了,启动完成后,直接输入地址+端口即可访问Eureka的管理后台: - -![image-20220323152537322](https://tva1.sinaimg.cn/large/e6c9d24egy1h0jui0bzpvj21l60u0wi1.jpg) - -可以看到目前还没有任何的服务注册到Eureka,我们接着来配置一下我们的三个微服务,首先还是需要导入Eureka依赖(注意别导错了,名称里面有个starter的才是): - -```xml - - org.springframework.cloud - spring-cloud-starter-netflix-eureka-client - -``` - -然后修改配置文件: - -```yaml -eureka: - client: - # 跟上面一样,需要指向Eureka服务端地址,这样才能进行注册 - service-url: - defaultZone: http://localhost:8888/eureka -``` - -OK,无需在启动类添加注解,直接启动就可以了,然后打开Eureka的服务管理页面,可以看到我们刚刚开启的服务: - -![image-20220323154722373](https://tva1.sinaimg.cn/large/e6c9d24egy1h0jv4mtugqj22lm0ec40f.jpg) - -可以看到`8082`端口上的服务器,已经成功注册到Eureka了,但是这个服务名称怎么会显示为UNKNOWN,我们需要修改一下: - -```yaml -spring: - application: - name: userservice -``` - -![image-20220323155305545](https://tva1.sinaimg.cn/large/e6c9d24egy1h0jval83b7j22ls0c60ul.jpg) - -当我们的服务启动之后,会每隔一段时间跟Eureka发送一次心跳包,这样Eureka就能够感知到我们的服务是否处于正常运行状态。 - -现在我们用同样的方法,将另外两个微服务也注册进来: - -![image-20220323155948425](https://tva1.sinaimg.cn/large/e6c9d24egy1h0jvhkpma7j22m80c2mzo.jpg) - -那么,现在我们怎么实现服务发现呢? - -也就是说,我们之前如果需要对其他微服务进行远程调用,那么就必须要知道其他服务的地址: - -```java -User user = template.getForObject("http://localhost:8082/user/"+uid, User.class); -``` - -而现在有了Eureka之后,我们可以直接向其进行查询,得到对应的微服务地址,这里直接将服务名称替换即可: - -```java -@Service -public class BorrowServiceImpl implements BorrowService { - - @Resource - BorrowMapper mapper; - - @Resource - RestTemplate template; - - @Override - public UserBorrowDetail getUserBorrowDetailByUid(int uid) { - List borrow = mapper.getBorrowsByUid(uid); - - //这里不用再写IP,直接写服务名称userservice - User user = template.getForObject("http://userservice/user/"+uid, User.class); - //这里不用再写IP,直接写服务名称bookservice - List bookList = borrow - .stream() - .map(b -> template.getForObject("http://bookservice/book/"+b.getBid(), Book.class)) - .collect(Collectors.toList()); - return new UserBorrowDetail(user, bookList); - } -} -``` - -接着我们手动将RestTemplate声明为一个Bean,然后添加`@LoadBalanced`注解,这样Eureka就会对服务的调用进行自动发现,并提供负载均衡: - -```java -@Configuration -public class BeanConfig { - @Bean - @LoadBalanced - RestTemplate template(){ - return new RestTemplate(); - } -} -``` - -现在我们就可以正常调用了: - -![image-20220323161809122](https://tva1.sinaimg.cn/large/e6c9d24egy1h0jw0nxoykj22720bqjsw.jpg) - -不对啊,不是说有负载均衡的能力吗,怎么个负载均衡呢? - -我们先来看看,同一个服务器实际上是可以注册很多个的,但是它们的端口不同,比如我们这里创建多个用户查询服务,我们现在将原有的端口配置修改一下,由IDEA中设定启动参数来决定,这样就可以多创建几个不同端口的启动项了: - -![image-20220323162858616](https://tva1.sinaimg.cn/large/e6c9d24egy1h0jwbxq00pj21ue0rcgqf.jpg) - -![image-20220323162926482](https://tva1.sinaimg.cn/large/e6c9d24egy1h0jwch1eb2j21ng08ead5.jpg) - -可以看到,在Eureka中,同一个服务出现了两个实例: - -![image-20220323163010052](https://tva1.sinaimg.cn/large/e6c9d24egy1h0jwd5r9bgj21w00c8mz4.jpg) - -现在我们稍微修改一下用户查询,然后进行远程调用,看看请求是不是均匀地分配到这两个服务端: - -```java -@RestController -public class UserController { - - @Resource - UserService service; - - @RequestMapping("/user/{uid}") - public User findUserById(@PathVariable("uid") int uid){ - System.out.println("我被调用拉!"); - return service.getUserById(uid); - } -} -``` - -![image-20220323163335257](https://tva1.sinaimg.cn/large/e6c9d24egy1h0jwgqas0qj22ci0batb0.jpg) - -可以看到,两个实例都能够均匀地被分配请求: - -![image-20220323163448765](https://tva1.sinaimg.cn/large/e6c9d24egy1h0jwi0a3yhj21tk0gqq9l.jpg) - -![image-20220323163457877](https://tva1.sinaimg.cn/large/e6c9d24egy1h0jwi5kezyj21me0hmq83.jpg) - -这样,服务自动发现以及简单的负载均衡就实现完成了,并且,如果某个微服务挂掉了,只要存在其他同样的微服务实例在运行,那么就不会导致整个微服务不可用,极大地保证了安全性。 - -### 注册中心高可用 - -各位可否想过这样的一个问题?虽然Eureka能够实现服务注册和发现,但是如果Eureka服务器崩溃了,岂不是所有需要用到服务发现的微服务就GG了? - -为了避免,这种问题,我们也可以像上面那样,搭建Eureka集群,存在多个Eureka服务器,这样就算挂掉其中一个,其他的也还在正常运行,就不会使得服务注册与发现不可用。当然,要是物理黑客直接炸了整个机房,那还是算了吧。 - -![image-20220323205531185](https://tva1.sinaimg.cn/large/e6c9d24ely1h0k41ady28j21jy0iwmzt.jpg) - -我们来看看如何搭建Eureka集群,这里由于机器配置不高,就搭建两个Eureka服务器组成集群。 - -首先我们需要修改一下Eureka服务端的配置文件,这里我们创建两个配置文件,: - -```yaml -server: - port: 8801 -spring: - application: - name: eurekaserver -eureka: - instance: - # 由于不支持多个localhost的Eureka服务器,但是又只有本地测试环境,所以就只能自定义主机名称了 - # 主机名称改为eureka01 - hostname: eureka01 - client: - fetch-registry: false - # 去掉register-with-eureka选项,让Eureka服务器自己注册到其他Eureka服务器,这样才能相互启用 - service-url: - # 注意这里填写其他Eureka服务器的地址,不用写自己的 - defaultZone: http://eureka01:8801/eureka -``` - -```yaml -server: - port: 8802 -spring: - application: - name: eurekaserver -eureka: - instance: - hostname: eureka02 - client: - fetch-registry: false - service-url: - defaultZone: http://eureka01:8801/eureka -``` - -这里由于我们修改成自定义的地址,需要在hosts文件中将其解析到172.0.0.1才能回到localhost,Mac下文件路径为`/etc/hosts`,Windows下为`C:\Windows\system32\drivers\etc\hosts`: - -![image-20220323210218653](https://tva1.sinaimg.cn/large/e6c9d24ely1h0k48bkgyoj212q07a0te.jpg) - -对创建的两个配置文件分别添加启动配置,直接使用`spring.profiles.active`指定启用的配置文件即可: - -![image-20220323212308857](https://tva1.sinaimg.cn/large/e6c9d24ely1h0k4u09lpxj22aw0py0y5.jpg) - -接着启动这两个注册中心,这两个Eureka管理页面都可以被访问,我们访问其中一个: - -![image-20220323210937341](https://tva1.sinaimg.cn/large/e6c9d24ely1h0k4fxnlxtj21yk0cytao.jpg) - -![image-20220323210619533](https://tva1.sinaimg.cn/large/e6c9d24ely1h0k4chvjv8j21rq07it9k.jpg) - -可以看到下方`replicas`中已经包含了另一个Eureka服务器的地址,并且是可用状态。 - -接着我们需要将我们的微服务配置也进行修改: - -```yaml -eureka: - client: - service-url: - # 将两个Eureka的地址都加入,这样就算有一个Eureka挂掉,也能完成注册 - defaultZone: http://localhost:8801/eureka, http://localhost:8802/eureka -``` - -可以看到,服务全部成功注册,并且两个Eureka服务端都显示为已注册: - -![image-20220323211032311](https://tva1.sinaimg.cn/large/e6c9d24ely1h0k4gvpxdtj21vc0dm41j.jpg) - -接着我们模拟一下,将其中一个Eureka服务器关闭掉,可以看到它会直接变成不可用状态: - -![image-20220323211354516](https://tva1.sinaimg.cn/large/e6c9d24ely1h0k4ke207fj21rm06w75k.jpg) - -当然,如果这个时候我们重启刚刚关闭的Eureka服务器,会自动同步其他Eureka服务器的数据。 - -*** - -## LoadBalancer 负载均衡 - -前面我们讲解了如何对服务进行拆分、如何通过Eureka服务器进行服务注册与发现,那么现在我们来看看,它的负载均衡到底是如何实现的,实际上之前演示的负载均衡是依靠LoadBalancer实现的。 - -在2020年前的SpringCloud版本是采用Ribbon作为负载均衡实现,但是2020年的版本之后SpringCloud把Ribbon移除了,进而用自己编写的LoadBalancer替代。 - -那么,负载均衡是如何进行的呢? - -### 负载均衡 - -实际上,在添加`@LoadBalanced`注解之后,会启用拦截器对我们发起的服务调用请求进行拦截(注意这里是针对我们发起的请求进行拦截),叫做`LoadBalancerInterceptor`,它实现`ClientHttpRequestInterceptor`接口: - -```java -@FunctionalInterface -public interface ClientHttpRequestInterceptor { - ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException; -} -``` - -主要是对`intercept`方法的实现: - -```java -public ClientHttpResponse intercept(final HttpRequest request, final byte[] body, final ClientHttpRequestExecution execution) throws IOException { - URI originalUri = request.getURI(); - String serviceName = originalUri.getHost(); - Assert.state(serviceName != null, "Request URI does not contain a valid hostname: " + originalUri); - return (ClientHttpResponse)this.loadBalancer.execute(serviceName, this.requestFactory.createRequest(request, body, execution)); -} -``` - -我们可以打个断点看看实际是怎么在执行的,可以看到: - -![image-20220323220519463](https://tva1.sinaimg.cn/large/e6c9d24ely1h0k61wb6pxj222y0cm77n.jpg) - -![image-20220323220548051](https://tva1.sinaimg.cn/large/e6c9d24ely1h0k62dm3knj21yi0fgwiz.jpg) - -服务端会在发起请求时执行这些拦截器。 - -那么这个拦截器做了什么事情呢,首先我们要明确,我们给过来的请求地址,并不是一个有效的主机名称,而是服务名称,那么怎么才能得到真正需要访问的主机名称呢,肯定是得找Eureka获取的。 - -我们来看看`loadBalancer.execute()`做了什么,它的具体实现为`BlockingLoadBalancerClient`: - -```java -//从上面给进来了服务的名称和具体的请求实体 -public T execute(String serviceId, LoadBalancerRequest request) throws IOException { - String hint = this.getHint(serviceId); - LoadBalancerRequestAdapter lbRequest = new LoadBalancerRequestAdapter(request, new DefaultRequestContext(request, hint)); - Set supportedLifecycleProcessors = this.getSupportedLifecycleProcessors(serviceId); - supportedLifecycleProcessors.forEach((lifecycle) -> { - lifecycle.onStart(lbRequest); - }); - //可以看到在这里会调用choose方法自动获取对应的服务实例信息 - ServiceInstance serviceInstance = this.choose(serviceId, lbRequest); - if (serviceInstance == null) { - supportedLifecycleProcessors.forEach((lifecycle) -> { - lifecycle.onComplete(new CompletionContext(Status.DISCARD, lbRequest, new EmptyResponse())); - }); - //没有发现任何此服务的实例就抛异常(之前的测试中可能已经遇到了) - throw new IllegalStateException("No instances available for " + serviceId); - } else { - //成功获取到对应服务的实例,这时就可以发起HTTP请求获取信息了 - return this.execute(serviceId, serviceInstance, lbRequest); - } -} -``` - -所以,实际上在进行负载均衡的时候,会向Eureka发起请求,选择一个可用的对应服务,然后会返回此服务的主机地址等信息: - -![image-20220324120741736](https://tva1.sinaimg.cn/large/e6c9d24ely1h0kuedkhinj221e0jin2y.jpg) - -### 自定义负载均衡策略 - -LoadBalancer默认提供了两种负载均衡策略: - -* RandomLoadBalancer - 随机分配策略 -* **(默认)** RoundRobinLoadBalancer - 轮询分配策略 - -现在我们希望修改默认的负载均衡策略,可以进行指定,比如我们现在希望用户服务采用随机分配策略,我们需要先创建随机分配策略的配置类(不用加`@Configuration`): - -```java -public class LoadBalancerConfig { - //将官方提供的 RandomLoadBalancer 注册为Bean - @Bean - public ReactorLoadBalancer randomLoadBalancer(Environment environment, LoadBalancerClientFactory loadBalancerClientFactory){ - String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME); - return new RandomLoadBalancer(loadBalancerClientFactory.getLazyProvider(name, ServiceInstanceListSupplier.class), name); - } -} -``` - -接着我们需要为对应的服务指定负载均衡策略,直接使用注解即可: - -```java -@Configuration -@LoadBalancerClient(value = "userservice", //指定为 userservice 服务,只要是调用此服务都会使用我们指定的策略 - configuration = LoadBalancerConfig.class) //指定我们刚刚定义好的配置类 -public class BeanConfig { - @Bean - @LoadBalanced - RestTemplate template(){ - return new RestTemplate(); - } -} -``` - -接着我们在`BlockingLoadBalancerClient`中添加断点,观察是否采用我们指定的策略进行请求: - -![image-20220324221750289](https://tva1.sinaimg.cn/large/e6c9d24ely1h0lc17or9aj221y07swhq.jpg) - -![image-20220324221713964](https://tva1.sinaimg.cn/large/e6c9d24ely1h0lc0mbsmqj21ye07yjuh.jpg) - -发现访问userservice服务的策略已经更改为我们指定的策略了。 - -### OpenFeign实现负载均衡 - -官方文档:https://docs.spring.io/spring-cloud-openfeign/docs/current/reference/html/ - -Feign和RestTemplate一样,也是HTTP客户端请求工具,但是它的使用方式更加便捷。首先是依赖: - -```xml - - org.springframework.cloud - spring-cloud-starter-openfeign - -``` - -接着在启动类添加`@EnableFeignClients`注解: - -```java -@SpringBootApplication -@EnableFeignClients -public class BorrowApplication { - public static void main(String[] args) { - SpringApplication.run(BorrowApplication.class, args); - } -} -``` - -那么现在我们需要调用其他微服务提供的接口,该怎么做呢?我们直接创建一个对应服务的接口类即可: - -```java -@FeignClient("userservice") //声明为userservice服务的HTTP请求客户端 -public interface UserClient { -} -``` - -接着我们直接创建所需类型的方法,比如我们之前的: - -```java -RestTemplate template = new RestTemplate(); -User user = template.getForObject("http://userservice/user/"+uid, User.class); -``` - -现在可以直接写成这样: - -```java -@FeignClient("userservice") -public interface UserClient { - - //路径保证和其他微服务提供的一致即可 - @RequestMapping("/user/{uid}") - User getUserById(@PathVariable("uid") int uid); //参数和返回值也保持一致 -} -``` - -接着我们直接注入使用(有Mybatis那味了): - -```java -@Resource -UserClient userClient; - -@Override -public UserBorrowDetail getUserBorrowDetailByUid(int uid) { - List borrow = mapper.getBorrowsByUid(uid); - - User user = userClient.getUserById(uid); - //这里不用再写IP,直接写服务名称bookservice - List bookList = borrow - .stream() - .map(b -> template.getForObject("http://bookservice/book/"+b.getBid(), Book.class)) - .collect(Collectors.toList()); - return new UserBorrowDetail(user, bookList); -} -``` - -访问,可以看到结果依然是正确的: - -![image-20220324181614387](https://tva1.sinaimg.cn/large/e6c9d24ely1h0l51tto72j229e080dhe.jpg) - -并且我们可以观察一下两个用户微服务的调用情况,也是以负载均衡的形式进行的。 - -按照同样的方法,我们接着将图书管理服务的调用也改成接口形式: - -![image-20220324181740566](https://tva1.sinaimg.cn/large/e6c9d24ely1h0l53boxmlj21j60bgq51.jpg) - -最后我们的Service代码就变成了: - -```java -@Service -public class BorrowServiceImpl implements BorrowService { - - @Resource - BorrowMapper mapper; - - @Resource - UserClient userClient; - - @Resource - BookClient bookClient; - - @Override - public UserBorrowDetail getUserBorrowDetailByUid(int uid) { - List borrow = mapper.getBorrowsByUid(uid); - - User user = userClient.getUserById(uid); - List bookList = borrow - .stream() - .map(b -> bookClient.getBookById(b.getBid())) - .collect(Collectors.toList()); - return new UserBorrowDetail(user, bookList); - } -} -``` - -继续访问进行测试: - -![image-20220324181910173](https://tva1.sinaimg.cn/large/e6c9d24ely1h0l54vuecvj226206igmz.jpg) - -OK,正常。 - -当然,Feign也有很多的其他配置选项,这里就不多做介绍了,详细请查阅官方文档。 - -*** - -## Hystrix 服务熔断 - -官方文档:https://cloud.spring.io/spring-cloud-static/spring-cloud-netflix/1.3.5.RELEASE/single/spring-cloud-netflix.html#_circuit_breaker_hystrix_clients - -我们知道,微服务之间是可以进行相互调用的,那么如果出现了下面的情况会导致什么问题? - -![image-20220324141230070](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ky07zn6tj219g07adgz.jpg) - -由于位于最底端的服务提供者E发生故障,那么此时会直接导致服务ABCD全线崩溃,就像雪崩了一样。 - -![image-20220324141706946](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ky50sw4jj219s07yabg.jpg) - -这种问题实际上是不可避免的,由于多种因素,比如网络卡顿、系统故障、硬件问题等,都存在一定可能,会导致这种极端的情况发生。因此,我们需要寻找一个应对这种极端情况的解决方案。 - -为了解决分布式系统的雪崩问题,SpringCloud提供了Hystrix熔断器组件,他就像我们家中的保险丝一样,当电流过载就会直接熔断,防止危险进一步发生,从而保证家庭用电安全。可以想象一下,如果整条链路上的服务已经全线崩溃,这时还在不断地有大量的请求到达,需要各个服务进行处理,肯定是会使得情况越来越糟糕的。 - -我们来详细看看它的工作机制。 - -### 服务降级 - -首先我们来看看服务降级,注意一定要区分开服务降级和服务熔断的区别,服务降级并不会直接返回错误,而是可以提供一个补救措施,正常响应给请求者。这样相当于服务依然可用,但是服务能力肯定是下降了的。 - -我们就基于借阅管理服务来进行讲解,我们不开启用户服务和图书服务,表示用户服务和图书服务已经挂掉了。 - -这里我们导入Hystrix的依赖(此项目已经停止维护,SpringCloud依赖中已经不自带了,所以说需要自己单独导入): - -```xml - - org.springframework.cloud - spring-cloud-starter-netflix-hystrix - 2.2.10.RELEASE - -``` - -接着我们需要在启动类添加注解开启: - -```java -@SpringBootApplication -@EnableHystrix //启用Hystrix -public class BorrowApplication { - public static void main(String[] args) { - SpringApplication.run(BorrowApplication.class, args); - } -} -``` - -那么现在,由于用户服务和图书服务不可用,所以查询借阅信息的请求肯定是没办法正常响应的,这时我们可以提供一个备选方案,也就是说当服务出现异常时,返回我们的备选方案: - -```java -@RestController -public class BorrowController { - - @Resource - BorrowService service; - - @HystrixCommand(fallbackMethod = "onError") //使用@HystrixCommand来指定备选方案 - @RequestMapping("/borrow/{uid}") - UserBorrowDetail findUserBorrows(@PathVariable("uid") int uid){ - return service.getUserBorrowDetailByUid(uid); - } - - //备选方案,这里直接返回空列表了 - //注意参数和返回值要和上面的一致 - UserBorrowDetail onError(int uid){ - return new UserBorrowDetail(null, Collections.emptyList()); - } -} -``` - -可以看到,虽然我们的服务无法正常运行了,但是依然可以给浏览器正常返回响应数据: - -![image-20220324150253610](https://tva1.sinaimg.cn/large/e6c9d24ely1h0kzgnuv0ej21x406ujvb.jpg) - -![image-20220324150310955](https://tva1.sinaimg.cn/large/e6c9d24ely1h0kzgygdd3j218s06qjru.jpg) - -服务降级是一种比较温柔的解决方案,虽然服务本身的不可用,但是能够保证正常响应数据。 - -### 服务熔断 - -熔断机制是应对雪崩效应的一种微服务链路保护机制,当检测出链路的某个微服务不可用或者响应时间太长时,会进行服务的降级,进而熔断该节点微服务的调用,快速返回”错误”的响应信息。当检测到该节点微服务响应正常后恢复调用链路。 - -实际上,熔断就是在降级的基础上进一步升级形成的,也就是说,在一段时间内多次调用失败,那么就直接升级为熔断。 - -我们可以添加两条输出语句: - -```java -@RestController -public class BorrowController { - - @Resource - BorrowService service; - - @HystrixCommand(fallbackMethod = "onError") - @RequestMapping("/borrow/{uid}") - UserBorrowDetail findUserBorrows(@PathVariable("uid") int uid){ - System.out.println("开始向其他服务获取信息"); - return service.getUserBorrowDetailByUid(uid); - } - - UserBorrowDetail onError(int uid){ - System.out.println("服务错误,进入备选方法!"); - return new UserBorrowDetail(null, Collections.emptyList()); - } -} -``` - -接着,我们在浏览器中疯狂点击刷新按钮,对此服务疯狂发起请求,可以看到后台: - -![image-20220324152044551](https://tva1.sinaimg.cn/large/e6c9d24ely1h0kzz87azgj21960hwwhz.jpg) - -一开始的时候,会正常地去调用Controller对应的方法`findUserBorrows`,发现失败然后进入备选方法,但是我们发现在持续请求一段时间之后,没有再调用这个方法,而是直接调用备选方案,这便是升级到了熔断状态。 - -我们可以继续不断点击,继续不断地发起请求: - -![image-20220324152750797](https://tva1.sinaimg.cn/large/e6c9d24ely1h0l06mgm5yj21uy0b0gns.jpg) - -可以看到,过了一段时间之后,会尝试正常执行一次`findUserBorrows`,但是依然是失败状态,所以继续保持熔断状态。 - -所以得到结论,它能够对一段时间内出现的错误进行侦测,当侦测到出错次数过多时,熔断器会打开,所有的请求会直接响应失败,一段时间后,只执行一定数量的请求,如果还是出现错误,那么则继续保持打开状态,否则说明服务恢复正常运行,关闭熔断器。 - -我们可以测试一下,开启另外两个服务之后,继续点击: - -![image-20220324153044583](https://tva1.sinaimg.cn/large/e6c9d24ely1h0l09mmg95j21ue0eatb5.jpg) - -可以看到,当另外两个服务正常运行之后,当再次尝试调用`findUserBorrows`之后会成功,于是熔断机制就关闭了,服务恢复运行。 - -总结一下: - -![image-20220324153935858](https://tva1.sinaimg.cn/large/e6c9d24ely1h0l0iulmatj21rc0ba0vj.jpg) - -### OpenFeign实现降级 - -Hystrix也可以配合Feign进行降级,我们可以对应接口中定义的远程调用单独进行降级操作。 - -比如我们还是以用户服务挂掉为例,那么这个时候肯定是会远程调用失败的,也就是说我们的Controller中的方法在执行过程中会直接抛出异常,进而被Hystrix监控到并进行服务降级。 - -而实际上导致方法执行异常的根源就是远程调用失败,所以我们换个思路,既然用户服务调用失败,那么我就给这个远程调用添加一个替代方案,如果此远程调用失败,那么就直接上替代方案。那么怎么实现替代方案呢?我们知道Feign都是以接口的形式来声明远程调用,那么既然远程调用已经失效,我们就自行对其进行实现,创建一个实现类,对原有的接口方法进行替代方案实现: - -```java -@Component //注意,需要将其注册为Bean,Feign才能自动注入 -public class UserFallbackClient implements UserClient{ - @Override - public User getUserById(int uid) { //这里我们自行对其进行实现,并返回我们的替代方案 - User user = new User(); - user.setName("我是替代方案"); - return user; - } -} -``` - -实现完成后,我们只需要在原有的接口中指定失败替代实现即可: - -```java -//fallback参数指定为我们刚刚编写的实现类 -@FeignClient(value = "userservice", fallback = UserFallbackClient.class) -public interface UserClient { - - @RequestMapping("/user/{uid}") - User getUserById(@PathVariable("uid") int uid); -} -``` - -现在去掉`BorrowController`的`@HystrixCommand`注解和备选方法: - -```java -@RestController -public class BorrowController { - - @Resource - BorrowService service; - - @RequestMapping("/borrow/{uid}") - UserBorrowDetail findUserBorrows(@PathVariable("uid") int uid){ - return service.getUserBorrowDetailByUid(uid); - } -} -``` - -最后我们在配置文件中开启熔断支持: - -```yaml -feign: - circuitbreaker: - enabled: true -``` - -启动服务,调用接口试试看: - -![image-20220325122629016](https://tva1.sinaimg.cn/large/e6c9d24ely1h0m0k7jve9j21zq03kdi6.jpg) - -![image-20220325122301779](https://tva1.sinaimg.cn/large/e6c9d24ely1h0m0gmj8ayj229c07q75v.jpg) - -可以看到,现在已经采用我们的替代方案作为结果。 - -### 监控页面部署 - -除了对服务的降级和熔断处理,我们也可以对其进行实时监控,只需要安装监控页面即可,这里我们创建一个新的项目,导入依赖: - -```xml - - org.springframework.cloud - spring-cloud-starter-netflix-hystrix-dashboard - 2.2.10.RELEASE - -``` - -接着添加配置文件: - -```yaml -server: - port: 8900 -hystrix: - dashboard: - # 将localhost添加到白名单,默认是不允许的 - proxy-stream-allow-list: "localhost" -``` - -接着创建主类,注意需要添加`@EnableHystrixDashboard`注解开启管理页面: - -```java -@SpringBootApplication -@EnableHystrixDashboard -public class HystrixDashBoardApplication { - public static void main(String[] args) { - SpringApplication.run(HystrixDashBoardApplication.class, args); - } -} -``` - -启动Hystrix管理页面服务,然后我们需要在要进行监控的服务中添加Actuator依赖: - -```xml - - org.springframework.boot - spring-boot-starter-actuator - -``` - -> Actuator是SpringBoot程序的监控系统,可以实现健康检查,记录信息等。在使用之前需要引入spring-boot-starter-actuator,并做简单的配置即可。 - -添加此依赖后,我们可以在IDEA中查看运行情况: - -![image-20220324225633805](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ld5ia0z8j21uw0e6god.jpg) - -然后在配置文件中配置Actuator添加暴露: - -```yaml -management: - endpoints: - web: - exposure: - include: '*' -``` - -接着我们打开刚刚启动的管理页面,地址为:http://localhost:8900/hystrix/ - -![image-20220324225733550](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ld6jtiijj22ij0u042v.jpg) - -在中间填写要监控的服务:比如借阅服务:http://localhost:8301/actuator/hystrix.stream,注意后面要添加`/actuator/hystrix.stream`,然后点击Monitor Stream即可进入监控页面: - -![image-20220324230515009](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ldejq3n0j22ly0puwhu.jpg) - -可以看到现在都是Loading状态,这是因为还没有开始统计,我们现在尝试调用几次我们的服务: - -![image-20220324230559068](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ldfbaoi5j22660bqgnc.jpg) - -可以看到,在调用之后,监控页面出现了信息: - -![image-20220324230703600](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ldgfihczj21wq0ksn03.jpg) - -可以看到5次访问都是正常的,所以显示为绿色,接着我们来尝试将图书服务关闭,这样就会导致服务降级甚至熔断,然后再多次访问此服务看看监控会如何变化: - -![image-20220324230923472](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ldiuq9naj222a0l2whx.jpg) - -可以看到,错误率直接飙升到100%,并且一段时间内持续出现错误,中心的圆圈也变成了红色,我们继续进行访问: - -![image-20220324231022133](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ldjvo0ppj21j20iy0v3.jpg) - -在出现大量错误的情况下保持持续访问,可以看到此时已经将服务熔断,`Circuit`更改为Open状态,并且图中的圆圈也变得更大,表示压力在持续上升。 - -*** - -## Gateway 路由网关 - -官网地址:https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/ - -说到路由,想必各位一定最先想到的就是家里的路由器了,那么我们家里的路由器充当的是一个什么角色呢? - -我们知道,如果我们需要连接互联网,那么就需要将手机或是电脑连接到家里的路由器才可以,而路由器则连接光猫,光猫再通过光纤连接到互联网,也就是说,互联网方向发送过来的数据,需要经过路由器才能到达我们的设备。而路由器充当的就是数据包中转站,所有的局域网设备都无法直接与互联网连接,而是需要经过路由器进行中转,我们一般说路由器下的网络是内网,而互联网那一端是外网。 - -![image-20220324164439809](https://tva1.sinaimg.cn/large/e6c9d24ely1h0l2ejn70ej21di0b4wfr.jpg) - -我们的局域网设备,无法被互联网上的其他设备直接访问,肯定是能够保证到安全性的。并互联网发送过来的数据,需要经过路由器进行解析,识别到底是哪一个设备的数据包,然后再发送给对应的设备。 - -而我们的微服务也是这样,一般情况下,可能并不是所有的微服务都需要直接暴露给外部调用,这时我们就可以使用路由机制,添加一层防护,让所有的请求全部通过路由来转发到各个微服务,并且转发给多个相同微服务实例也可以实现负载均衡。 - -![image-20220325130147758](https://tva1.sinaimg.cn/large/e6c9d24ely1h0m1kz3kycj21iq0huwhb.jpg) - -在之前,路由的实现一般使用Zuul,但是已经停更,而现在新出现了由SpringCloud官方开发的Gateway路由,它相比Zuul不仅性能上得到了一定的提升,并且是官方推出,契合性也会更好,所以我们这里就主要讲解Gateway。 - -### 部署网关 - -现在我们来创建一个新的项目,作为我们的网关,这里需要添加两个依赖: - -```xml - - - org.springframework.cloud - spring-cloud-starter-gateway - - - org.springframework.cloud - spring-cloud-starter-netflix-eureka-client - - -``` - -第一个依赖就是网关的依赖,而第二个则跟其他微服务一样,需要注册到Eureka才能生效,注意别添加Web依赖,使用的是WebFlux框架。 - -然后我们来完善一下配置文件: - -```yaml -server: - port: 8500 -eureka: - client: - service-url: - defaultZone: http://localhost:8801/eureka, http://localhost:8802/eureka -spring: - application: - name: gateway -``` - -现在就可以启动了: - -![image-20220324170951878](https://tva1.sinaimg.cn/large/e6c9d24ely1h0l34rlri8j22dw0b80ve.jpg) - -但是现在还没有配置任何的路由功能,我们接着将路由功能进行配置: - -```yaml -spring: - cloud: - gateway: - # 配置路由,注意这里是个列表,每一项都包含了很多信息 - routes: - - id: borrow-service # 路由名称 - uri: lb://borrowservice # 路由的地址,lb表示使用负载均衡到微服务,也可以使用http正常转发 - predicates: # 路由规则,断言什么请求会被路由 - - Path=/borrow/** # 只要是访问的这个路径,一律都被路由到上面指定的服务 -``` - -路由规则的详细列表(断言工厂列表)在这里:https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gateway-request-predicates-factories,可以指定多种类型,包括指定时间段、Cookie携带情况、Header携带情况、访问的域名地址、访问的方法、路径、参数、访问者IP等。也可以使用配置类进行配置,但是还是推荐直接配置文件,省事。 - -接着启动网关,搭载Arm架构芯片的Mac电脑可能会遇到这个问题: - -![image-20220325150924472](https://tva1.sinaimg.cn/large/e6c9d24ely1h0m59qpakvj21og074q5a.jpg) - -这是因为没有找到适用于此架构的动态链接库,不影响使用,无视即可,希望以后的版本能修复吧。 - -可以看到,我们现在可以直接通过路由来访问我们的服务了: - -![image-20220324171724493](https://tva1.sinaimg.cn/large/e6c9d24ely1h0l3cme88qj226g0a6abq.jpg) - -注意此时依然可以通过原有的服务地址进行访问: - -![image-20220324171909828](https://tva1.sinaimg.cn/large/e6c9d24ely1h0l3efx1npj225c070400.jpg) - -这样我们就可以将不需要外网直接访问的微服务全部放到内网环境下,而只依靠网关来对外进行交涉。 - -### 路由过滤器 - -路由过滤器支持以某种方式修改传入的 HTTP 请求或传出的 HTTP 响应,路由过滤器的范围是某一个路由,跟之前的断言一样,Spring Cloud Gateway 也包含许多内置的路由过滤器工厂,详细列表:https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gatewayfilter-factories - -比如我们现在希望在请求到达时,在请求头中添加一些信息再转发给我们的服务,那么这个时候就可以使用路由过滤器来完成,我们只需要对配置文件进行修改: - -```yaml -spring: - application: - name: gateway - cloud: - gateway: - routes: - - id: borrow-service - uri: lb://borrowservice - predicates: - - Path=/borrow/** - # 继续添加新的路由配置,这里就以书籍管理服务为例 - # 注意-要对齐routes: - - id: book-service - uri: lb://bookservice - predicates: - - Path=/book/** - filters: # 添加过滤器 - - AddRequestHeader=Test, HelloWorld! - # AddRequestHeader 就是添加请求头信息,其他工厂请查阅官网 -``` - -接着我们在BookController中获取并输出一下,看看是不是成功添加了: - -```java -@RestController -public class BookController { - - @Resource - BookService service; - - @RequestMapping("/book/{bid}") - Book findBookById(@PathVariable("bid") int bid, - HttpServletRequest request){ - System.out.println(request.getHeader("Test")); - return service.getBookById(bid); - } -} -``` - -现在我们通过Gateway访问我们的图书管理服务: - -![image-20220325150730814](https://tva1.sinaimg.cn/large/e6c9d24ely1h0m57rm7t4j21bq07kt9c.jpg) - -![image-20220325151220776](https://tva1.sinaimg.cn/large/e6c9d24ely1h0m5cstkq3j21v403q0ud.jpg) - -可以看到这里成功获取到由网关添加的请求头信息了。 - -除了针对于某一个路由配置过滤器之外,我们也可以自定义全局过滤器,它能够作用于全局。但是我们需要通过代码的方式进行编写,比如我们要实现拦截没有携带指定请求参数的请求: - -```java -@Component //需要注册为Bean -public class TestFilter implements GlobalFilter { - @Override - public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { //只需要实现此方法 - return null; - } -} -``` - -接着我们编写判断: - -```java -@Override -public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { - //先获取ServerHttpRequest对象,注意不是HttpServletRequest - ServerHttpRequest request = exchange.getRequest(); - //打印一下所有的请求参数 - System.out.println(request.getQueryParams()); - //判断是否包含test参数,且参数值为1 - List value = request.getQueryParams().get("test"); - if(value != null && value.contains("1")) { - //将ServerWebExchange向过滤链的下一级传递(跟JavaWeb中介绍的过滤器其实是差不多的) - return chain.filter(exchange); - }else { - //直接在这里不再向下传递,然后返回响应 - return exchange.getResponse().setComplete(); - } -} -``` - -可以看到结果: - -![image-20220325154443063](https://tva1.sinaimg.cn/large/e6c9d24ely1h0m6ahb0zvj21ak07qaah.jpg) - -![image-20220325154508853](https://tva1.sinaimg.cn/large/e6c9d24ely1h0m6axjl3oj21ai072t9h.jpg) - -成功实现规则判断和拦截操作。 - -当然,过滤器肯定是可以存在很多个的,所以我们可以手动指定过滤器之间的顺序: - -```java -@Component -public class TestFilter implements GlobalFilter, Ordered { //实现Ordered接口 - - @Override - public int getOrder() { - return 0; - } -``` - -注意Order的值越小优先级越高,并且无论是在配置文件中编写的单个路由过滤器还是全局路由过滤器,都会受到Order值影响(单个路由的过滤器Order值按从上往下的顺序从1开始递增),最终是按照Order值决定哪个过滤器优先执行,当Order值一样时 全局路由过滤器执行 `优于` 单独的路由过滤器执行。 - -*** - -## Config 配置中心 - -**官方文档:**https://docs.spring.io/spring-cloud-config/docs/current/reference/html/ - -经过前面的学习,我们对于一个分布式应用的技术选型和搭建已经了解得比较多了,但是各位有没有发现一个问题,如果我们的微服务项目需要部署很多个实例,那么配置文件我们岂不是得一个一个去改,可能十几个实例还好,要是有几十个上百个呢?那我们一个一个去配置,岂不直接猝死在工位上。 - -所以,我们需要一种更加高级的集中化地配置文件管理工具,集中地对配置文件进行配置。 - -> Spring Cloud Config 为分布式系统中的外部配置提供服务器端和客户端支持。使用 Config Server,您可以集中管理所有环境中应用程序的外部配置。 - -![image-20220325171754862](https://tva1.sinaimg.cn/large/e6c9d24ely1h0m8zggbzyj21ha0csjt5.jpg) - -实际上Spring Cloud Config就是一个配置中心,所有的服务都可以从配置中心取出配置,而配置中心又可以从GitHub远程仓库中获取云端的配置文件,这样我们只需要修改GitHub中的配置即可对所有的服务进行配置管理了。 - -### 部署配置中心 - -这里我们接着创建一个新的项目,并导入依赖: - -```xml - - - org.springframework.cloud - spring-cloud-config-server - - - org.springframework.cloud - spring-cloud-starter-netflix-eureka-client - - -``` - -老规矩,启动类: - -```java -@SpringBootApplication -@EnableConfigServer -public class ConfigApplication { - public static void main(String[] args) { - SpringApplication.run(ConfigApplication.class, args); - } -} -``` - -接着就是配置文件: - -```yaml -server: - port: 8700 -spring: - application: - name: configserver -eureka: - client: - service-url: - defaultZone: http://localhost:8801/eureka, http://localhost:8802/eureka -``` - -先启动一次看看,能不能成功: - -![image-20220325173932623](https://tva1.sinaimg.cn/large/e6c9d24ely1h0m9lyfoz2j226409uabs.jpg) - -这里我们以本地仓库为例(就不用GitHub了,卡到怀疑人生了),首先在项目目录下创建一个本地Git仓库,打开终端,在桌面上创建一个新的本地仓库: - -![image-20220325220843990](https://tva1.sinaimg.cn/large/e6c9d24ely1h0mhe24rhjj211q05cabb.jpg) - -然后我们在文件夹中随便创建一些配置文件,注意名称最好是{服务名称}-{环境}.yml: - -![image-20220325221411834](https://tva1.sinaimg.cn/large/e6c9d24ely1h0mhjqi2o6j216205gaan.jpg) - -然后我们在配置文件中,添加本地仓库的一些信息(远程仓库同理),详细使用教程:https://docs.spring.io/spring-cloud-config/docs/current/reference/html/#_git_backend - -```yaml -spring: - cloud: - config: - server: - git: - # 这里填写的是本地仓库地址,远程仓库直接填写远程仓库地址 http://git... - uri: file://${user.home}/Desktop/config-repo - # 默认分支设定为你自己本地或是远程分支的名称 - default-label: main -``` - -然后启动我们的配置服务器,通过以下格式进行访问: - -* http://localhost:8700/{服务名称}/{环境}/{Git分支} -* http://localhost:8700/{Git分支}/{服务名称}-{环境}.yml - -比如我们要访问图书服务的生产环境代码,可以使用 http://localhost:8700/bookservice/prod/main 链接,它会显示详细信息: - -![image-20220325221946363](https://tva1.sinaimg.cn/large/e6c9d24ely1h0mhpjeaiyj22is0cadjs.jpg) - -也可以使用 http://localhost:8700/main/bookservice-prod.yml 链接,它仅显示配置文件原文: - -![image-20220325222309095](https://tva1.sinaimg.cn/large/e6c9d24ely1h0mht1siqdj21ro0hu41a.jpg) - -当然,除了使用Git来保存之外,还支持一些其他的方式,详细情况请查阅官网。 - -### 客户端配置 - -服务端配置完成之后,我们接着来配置一下客户端,那么现在我们的服务既然需要从服务器读取配置文件,那么就需要进行一些配置,我们删除原来的`application.yml`文件(也可以保留,最后无论是远端配置还是本地配置都会被加载),改用`bootstrap.yml`(在application.yml之前加载,可以实现配置文件远程获取): - -```xml - - org.springframework.cloud - spring-cloud-starter-config - - - - org.springframework.cloud - spring-cloud-starter-bootstrap - -``` - -```yaml -spring: - cloud: - config: - # 名称,其实就是文件名称 - name: bookservice - # 配置服务器的地址 - uri: http://localhost:8700 - # 环境 - profile: prod - # 分支 - label: main -``` - -配置完成之后,启动图书服务: - -![image-20220325224708591](https://tva1.sinaimg.cn/large/e6c9d24ely1h0mii0tbegj22l208k43a.jpg) - -可以看到已经从远端获取到了配置,并进行启动。 - -*** - -## 微服务CAP原则 - -经过前面的学习,我们对SpringCloud Netflix以及SpringCloud官方整个生态下的组件认识也差不多了,入门教学就到此为止,下一章将开启真正精彩的正片部分,本章的最后我们还是来了解一些理论上的知识。 - -![image-20220325230915356](https://tva1.sinaimg.cn/large/e6c9d24ely1h0mj50oc1zj212e0jw756.jpg) - -> CAP原则又称CAP定理,指的是在一个分布式系统中,存在Consistency(一致性)、Availability(可用性)、Partition tolerance(分区容错性),三者不可同时保证,最多只能保证其中的两者。 -> -> 一致性(C):在分布式系统中的所有数据备份,在同一时刻都是同样的值(所有的节点无论何时访问都能拿到最新的值) -> -> 可用性(A):系统中非故障节点收到的每个请求都必须得到响应(比如我们之前使用的服务降级和熔断,其实就是一种维持可用性的措施,虽然服务返回的是没有什么意义的数据,但是不至于用户的请求会被服务器忽略) -> -> 分区容错性(P):一个分布式系统里面,节点之间组成的网络本来应该是连通的,然而可能因为一些故障(比如网络丢包等,这是很难避免的),使得有些节点之间不连通了,整个网络就分成了几块区域,数据就散布在了这些不连通的区域中(这样就可能出现某些被分区节点存放的数据访问失败,我们需要来容忍这些不可靠的情况) - -总的来说,数据存放的节点数越多,分区容忍性就越高,但是要复制更新的次数就越多,一致性就越难保证。同时为了保证一致性,更新所有节点数据所需要的时间就越长,那么可用性就会降低。 - -所以说,只能存在以下三种方案: - -### AC 可用性+一致性 - -要同时保证可用性和一致性,代表着某个节点数据更新之后,需要立即将结果通知给其他节点,并且要尽可能的快,这样才能及时响应保证可用性,这就对网络的稳定性要求非常高,但是实际情况下,网络很容易出现丢包等情况,并不是一个可靠的传输,如果需要避免这种问题,就只能将节点全部放在一起,但是这显然违背了分布式系统的概念,所以对于我们的分布式系统来说,很难接受。 - -### CP 一致性+分区容错性 - -为了保证一致性,那么就得将某个节点的最新数据发送给其他节点,并且需要等到所有节点都得到数据才能进行响应,同时有了分区容错性,那么代表我们可以容忍网络的不可靠问题,所以就算网络出现卡顿,那么也必须等待所有节点完成数据同步,才能进行响应,因此就会导致服务在一段时间内完全失效,所以可用性是无法得到保证的。 - -### AP 可用性+分区容错性 - -既然CP可能会导致一段时间内服务得不到任何响应,那么要保证可用性,就只能放弃节点之间数据的高度统一,也就是说可以在数据不统一的情况下,进行响应,因此就无法保证一致性了。虽然这样会导致拿不到最新的数据,但是只要数据同步操作在后台继续运行,一定能够在某一时刻完成所有节点数据的同步,那么就能实现**最终一致性**,所以AP实际上是最能接受的一种方案。 - -比如我们实现的Eureka集群,它使用的就是AP方案,Eureka各个节点都是平等的,少数节点挂掉不会影响正常节点的工作,剩余的节点依然可以提供注册和查询服务。而Eureka客户端在向某个Eureka服务端注册时如果发现连接失败,则会自动切换至其他节点。只要有一台Eureka服务器正常运行,那么就能保证服务可用**(A)**,只不过查询到的信息可能不是最新的**(C)** - -在之后的章节,我们还会继续了解这些理论的其他实际应用。 \ No newline at end of file diff --git a/青空笔记/SpringCloud笔记/SpringCloud笔记(三).md b/青空笔记/SpringCloud笔记/SpringCloud笔记(三).md deleted file mode 100644 index 538c028..0000000 --- a/青空笔记/SpringCloud笔记/SpringCloud笔记(三).md +++ /dev/null @@ -1,1979 +0,0 @@ -# 微服务应用 - -前面我们已经完成了SpringCloudAlibaba的学习,我们对一个微服务项目的架构体系已经有了一定的了解,那么本章我们将在应用层面继续探讨微服务。 - -## 分布式权限校验 - -虽然完成前面的部分,我们已经可以自己去编写一个比较中规中矩的微服务项目了,但是还有一个问题我们没有解决,登录问题。假如现在要求用户登录之后,才能进行图书的查询、借阅等操作,那么我们又该如何设计这个系统呢? - -回顾我们之前进行权限校验的原理,服务器是如何判定一个请求是来自哪个用户的呢? - -* 首先浏览器会向服务端发送请求,访问我们的网站。 -* 服务端收到请求后,会创建一个SESSION ID,并暂时存储在服务端,然后会发送给浏览器作为Cookie保存。 -* 之后浏览器会一直携带此Cookie访问服务器,这样在收到请求后,就能根据携带的Cookie中的SESSION ID判断是哪个用户了。 -* 这样服务端和浏览器之间可以轻松地建立会话了。 - -但是我们想一下,我们现在采用的是分布式的系统,那么在用户服务进行登录之后,其他服务比如图书服务和借阅服务,它们会知道用户登录了吗? - -![image-20220401220634827](https://tva1.sinaimg.cn/large/e6c9d24ely1h0uko0a27mj21ia08y0u9.jpg) - -实际上我们登录到用户服务之后,Session中的用户数据只会在用户服务的应用中保存,而在其他服务中,并没有对应的信息,但是我们现在希望的是,所有的服务都能够同步这些Session信息,这样我们才能实现在用户服务登录之后其他服务都能知道,那么我们该如何实现Session的同步呢? - -1. 我们可以在每台服务器上都复制一份Session,但是这样显然是很浪费时间的,并且用户验证数据占用的内存会成倍的增加。 -2. 将Session移出服务器,用统一存储来存放,比如我们可以直接在Redis或是MySQL中存放用户的Session信息,这样所有的服务器在需要获取Session信息时,统一访问Redis或是MySQL即可,这样就能保证所有服务都可以同步Session了(是不是越来越感觉只要有问题,没有什么是加一个中间件解决不了的) - -![image-20220402111827054](https://tva1.sinaimg.cn/large/e6c9d24ely1h0v7jwaum4j21qk0amq4h.jpg) - -那么,我们就着重来研究一下,然后实现2号方案,这里我们就使用Redis作为Session统一存储,我们把一开始的压缩包重新解压一次,又来从头开始编写吧。 - -这里我们就只使用Nacos就行了,和之前一样,我们把Nacos的包导入一下,然后进行一些配置: - -![image-20220402105512397](https://tva1.sinaimg.cn/large/e6c9d24ely1h0v6vpkivcj22ca0baju3.jpg) - -现在我们需要为每个服务都添加验证机制,首先导入依赖: - -```xml - - - org.springframework.session - spring-session-data-redis - - - - org.springframework.boot - spring-boot-starter-data-redis - -``` - -然后我们依然使用SpringSecurity框架作为权限校验框架: - -```xml - - org.springframework.boot - spring-boot-starter-security - -``` - -接着我们在每个服务都编写一下对应的配置文件: - -```yaml -spring: - session: - # 存储类型修改为redis - store-type: redis - redis: - # Redis服务器的信息,该咋写咋写 - host: 1.14.121.107 -``` - -这样,默认情况下,每个服务的接口都会被SpringSecurity所保护,只有登录成功之后,才可以被访问。 - -我们来打开Nacos看看: - -![image-20220402105638986](https://tva1.sinaimg.cn/large/e6c9d24ely1h0v6x7rkvuj22cm09sq4s.jpg) - -可以看到三个服务都正常注册了,接着我们去访问图书服务: - -![image-20220402105803658](https://tva1.sinaimg.cn/large/e6c9d24ely1h0v6yoo6hbj226g0jkjsy.jpg) - -可以看到,访问失败,直接把我们给重定向到登陆页面了,也就是说必须登陆之后才能访问,同样的方式去访问其他服务,也是一样的效果。 - -由于现在是统一Session存储,那么我们就可以在任意一个服务登录之后,其他服务都可以正常访问,现在我们在当前页面登录,登录之后可以看到图书服务能够正常访问了: - -![image-20220402110245827](https://tva1.sinaimg.cn/large/e6c9d24ely1h0v73kpsaaj219u06a0tg.jpg) - -同时用户服务也能正常访问了: - -![image-20220402110328674](https://tva1.sinaimg.cn/large/e6c9d24ely1h0v74bgewzj216m06874r.jpg) - -我们可以查看一下Redis服务器中是不是存储了我们的Session信息: - -![image-20220402110416031](https://tva1.sinaimg.cn/large/e6c9d24ely1h0v754p9ihj20z8048aax.jpg) - -虽然看起来好像确实没啥问题了,但是借阅服务炸了,我们来看看为什么: - -![image-20220402110519893](https://tva1.sinaimg.cn/large/e6c9d24ely1h0v768pgt2j21sk096q7k.jpg) - -在RestTemplate进行远程调用的时候,由于我们的请求没有携带对应SESSION的Cookie,所以导致验证失败,访问不成功,返回401,所以虽然这种方案看起来比较合理,但是在我们的实际使用中,还是存在一些不便的。 - -*** - -## OAuth 2.0 实现单点登录 - -**注意:**第一次接触可能会比较难,不太好理解,需要多实践和观察。 - -前面我们虽然使用了统一存储来解决Session共享问题,但是我们发现就算实现了Session共享,依然存在一些问题,由于我们每个服务都有自己的验证模块,实际上整个系统是存在冗余功能的、同时还有我们上面出现的问题,那么能否实现只在一个服务进行登录,就可以访问其他的服务呢? - -![image-20220408223238760](https://tva1.sinaimg.cn/large/e6c9d24ely1h12or8uglrj21s20gawh8.jpg) - -实际上之前的登录模式称为多点登录,而我们希望的是实现单点登陆,因此,我们得找一个更好的解决方案。 - -这里我们首先需要了解一种全新的登录方式:**OAuth 2.0**,我们经常看到一些网站支持第三方登录,比如淘宝、咸鱼我们就可以使用支付宝进行登录,腾讯游戏可以用QQ或是微信登陆,以及微信小程序都可以直接使用微信进行登录。我们知道它们并不是属于同一个系统,比如淘宝和咸鱼都不属于支付宝这个应用,但是由于需要获取支付宝的用户信息,这时我们就需要使用 OAuth2.0 来实现第三方授权,基于第三方应用访问用户信息的权限(本质上就是给别人调用自己服务接口的权限),那么它是如何实现的呢? - -### 四种授权模式 - -我们还是从理论开始讲解,OAuth 2.0一共有四种授权模式: - -1. **客户端模式(Client Credentials)** - - 这是最简单的一种模式,我们可以直接向验证服务器请求一个Token(这里可能有些小伙伴对Token的概念不是很熟悉,Token相当于是一个令牌,我们需要在验证服务器**(User Account And Authentication)**服务拿到令牌之后,才能去访问资源,比如用户信息、借阅信息等,这样资源服务器才能知道我们是谁以及是否成功登录了) - - 当然,这里的前端页面只是一个例子,它还可以是其他任何类型的**客户端**,比如App、小程序甚至是第三方应用的服务。 - - ![image-20220409213716233](https://tva1.sinaimg.cn/large/e6c9d24ely1h13srxpu4pj21720940tz.jpg) - - 虽然这种模式比较简便,但是已经失去了用户验证的意义,压根就不是给用户校验准备的,而是更适用于服务内部调用的场景。 - -2. **密码模式(Resource Owner Password Credentials)** - - 密码模式相比客户端模式,就多了用户名和密码的信息,用户需要提供对应账号的用户名和密码,才能获取到Token。 - - ![image-20220409213646255](https://tva1.sinaimg.cn/large/e6c9d24ely1h13srezf2xj218k09c0u6.jpg) - - 虽然这样看起来比较合理,但是会直接将账号和密码泄露给客户端,需要后台完全信任客户端不会拿账号密码去干其他坏事,所以这也不是我们常见的。 - -3. **隐式授权模式(Implicit Grant)** - - 首先用户访问页面时,会重定向到认证服务器,接着认证服务器给用户一个认证页面,等待用户授权,用户填写信息完成授权后,认证服务器返回Token。 - - ![image-20220409211722092](https://tva1.sinaimg.cn/large/e6c9d24ely1h13s7a0nxzj21ey0da0uw.jpg) - - 它适用于没有服务端的第三方应用页面,并且相比前面一种形式,验证都是在验证服务器进行的,敏感信息不会轻易泄露,但是Token依然存在泄露的风险。 - -4. **授权码模式(Authrization Code)** - - 这种模式是最安全的一种模式,也是推荐使用的一种,比如我们手机上的很多App都是使用的这种模式。 - - 相比隐式授权模式,它并不会直接返回Token,而是返回授权码,真正的Token是通过应用服务器访问验证服务器获得的。在一开始的时候,应用服务器(客户端通过访问自己的应用服务器来进而访问其他服务)和验证服务器之间会共享一个`secret`,这个东西没有其他人知道,而验证服务器在用户验证完成之后,会返回一个授权码,应用服务器最后将授权码和`secret`一起交给验证服务器进行验证,并且Token也是在服务端之间传递,不会直接给到客户端。 - - ![image-20220409223317823](https://tva1.sinaimg.cn/large/e6c9d24ely1h13ue89sjxj21dg0fq40y.jpg) - - 这样就算有人中途窃取了授权码,也毫无意义,因为,Token的获取必须同时携带授权码和secret,但是`secret`第三方是无法得知的,并且Token不会直接丢给客户端,大大减少了泄露的风险。 - -但是乍一看,OAuth 2.0不应该是那种第三方应用为了请求我们的服务而使用的吗,而我们这里需要的只是实现同一个应用内部服务之间的认证,其实我也可以利用 OAuth2.0 来实现单点登录,只是少了资源服务器这一角色,客户端就是我们的整个系统,接下来就让我们来实现一下。 - -### 搭建验证服务器 - -第一步就是最重要的,我们需要搭建一个验证服务器,它是我们进行权限校验的核心,验证服务器有很多的第三方实现也有Spring官方提供的实现,这里我们使用Spring官方提供的验证服务器。 - -这里我们将最开始保存好的项目解压,就重新创建一个新的项目,首先我们在父项目中添加最新的SpringCloud依赖: - -```xml - - org.springframework.cloud - spring-cloud-dependencies - 2021.0.1 - pom - import - -``` - -接着创建一个新的模块`auth-service`,添加依赖: - -```xml - - - org.springframework.boot - spring-boot-starter-web - - - - org.springframework.boot - spring-boot-starter-security - - - - - org.springframework.cloud - spring-cloud-starter-oauth2 - 2.2.5.RELEASE - - -``` - -接着我们修改一下配置文件: - -```yaml -server: - port: 8500 - servlet: - #为了防止一会在服务之间跳转导致Cookie打架(因为所有服务地址都是localhost,都会存JSESSIONID) - #这里修改一下context-path,这样保存的Cookie会使用指定的路径,就不会和其他服务打架了 - #但是注意之后的请求都得在最前面加上这个路径 - context-path: /sso -``` - -接着我们需要编写一下配置类,这里需要两个配置类,一个是OAuth2的配置类,还有一个是SpringSecurity的配置类: - -```java -@Configuration -public class SecurityConfiguration extends WebSecurityConfigurerAdapter { - - @Override - protected void configure(HttpSecurity http) throws Exception { - http - .authorizeRequests() - .anyRequest().authenticated() // - .and() - .formLogin().permitAll(); //使用表单登录 - } - - @Override - protected void configure(AuthenticationManagerBuilder auth) throws Exception { - BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); - auth - .inMemoryAuthentication() //直接创建一个用户,懒得搞数据库了 - .passwordEncoder(encoder) - .withUser("test").password(encoder.encode("123456")).roles("USER"); - } - - @Bean //这里需要将AuthenticationManager注册为Bean,在OAuth配置中使用 - @Override - public AuthenticationManager authenticationManagerBean() throws Exception { - return super.authenticationManagerBean(); - } -} -``` - -```java -@EnableAuthorizationServer //开启验证服务器 -@Configuration -public class OAuth2Configuration extends AuthorizationServerConfigurerAdapter { - - @Resource - private AuthenticationManager manager; - - private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); - - /** - * 这个方法是对客户端进行配置,一个验证服务器可以预设很多个客户端, - * 之后这些指定的客户端就可以按照下面指定的方式进行验证 - * @param clients 客户端配置工具 - */ - @Override - public void configure(ClientDetailsServiceConfigurer clients) throws Exception { - clients - .inMemory() //这里我们直接硬编码创建,当然也可以像Security那样自定义或是使用JDBC从数据库读取 - .withClient("web") //客户端名称,随便起就行 - .secret(encoder.encode("654321")) //只与客户端分享的secret,随便写,但是注意要加密 - .autoApprove(false) //自动审批,这里关闭,要的就是一会体验那种感觉 - .scopes("book", "user", "borrow") //授权范围,这里我们使用全部all - .authorizedGrantTypes("client_credentials", "password", "implicit", "authorization_code", "refresh_token"); - //授权模式,一共支持5种,除了之前我们介绍的四种之外,还有一个刷新Token的模式 - //这里我们直接把五种都写上,方便一会实验,当然各位也可以单独只写一种一个一个进行测试 - //现在我们指定的客户端就支持这五种类型的授权方式了 - } - - @Override - public void configure(AuthorizationServerSecurityConfigurer security) { - security - .passwordEncoder(encoder) //编码器设定为BCryptPasswordEncoder - .allowFormAuthenticationForClients() //允许客户端使用表单验证,一会我们POST请求中会携带表单信息 - .checkTokenAccess("permitAll()"); //允许所有的Token查询请求 - } - - @Override - public void configure(AuthorizationServerEndpointsConfigurer endpoints) { - endpoints - .authenticationManager(manager); - //由于SpringSecurity新版本的一些底层改动,这里需要配置一下authenticationManager,才能正常使用password模式 - } -} -``` - -接着我们就可以启动服务器了: - -![image-20220410123941157](https://tva1.sinaimg.cn/large/e6c9d24ely1h14iuwapslj220008ejx2.jpg) - -然后我们使用Postman进行接口测试,首先我们从最简单的客户端模式进行测试,客户端模式只需要提供id和secret即可直接拿到Token,注意需要再添加一个grant_type来表明我们的授权方式,默认请求路径为http://localhost:8500/sso/oauth/token: - -![image-20220410125437766](https://tva1.sinaimg.cn/large/e6c9d24ely1h14jafpizcj21cw0eg76a.jpg) - -发起请求后,可以看到我们得到了Token,它是以JSON格式给到我们的: - -![image-20220410125831300](https://tva1.sinaimg.cn/large/e6c9d24ely1h14jehayojj21cw0am0ty.jpg) - -我们还可以访问 http://localhost:8500/sso/oauth/check_token 来验证我们的Token是否有效: - -![image-20220410130204734](https://tva1.sinaimg.cn/large/e6c9d24ely1h14ji6rg42j21bk0cgmym.jpg) - -![image-20220410130223312](https://tva1.sinaimg.cn/large/e6c9d24ely1h14jiieikmj21bi0ew75d.jpg) - -可以看到active为true,表示我们刚刚申请到的Token是有效的。 - -接着我们来测试一下第二种password模式,我们还需要提供具体的用户名和密码,授权模式定义为password即可: - -![image-20220410130958929](https://tva1.sinaimg.cn/large/e6c9d24ely1h14jqelib5j21bg0fg0u9.jpg) - -接着我们需要在请求头中添加Basic验证信息,这里我们直接填写id和secret即可: - -![image-20220410130819980](https://tva1.sinaimg.cn/large/e6c9d24ely1h14jooye3nj21b00d4q4n.jpg) - -可以看到在请求头中自动生成了Basic验证相关内容: - -![image-20220410130942662](https://tva1.sinaimg.cn/large/e6c9d24ely1h14jq4t7y9j21a207s751.jpg) - -![image-20220410131048992](https://tva1.sinaimg.cn/large/e6c9d24ely1h14jra2ysyj21ay0bgta5.jpg) - -响应成功,得到Token信息,并且这里还多出了一个refresh_token,这是用于刷新Token的,我们之后会进行讲解。 - -![image-20220410131641887](https://tva1.sinaimg.cn/large/e6c9d24ely1h14jxemfuxj21d20fcjsq.jpg) - -查询Token信息之后还可以看到登录的具体用户以及角色权限等。 - -接着我们来看隐式授权模式,这种模式我们需要在验证服务器上进行登录操作,而不是直接请求Token,验证登录请求地址:http://localhost:8500/sso/oauth/authorize?client_id=web&response_type=token - -注意response_type一定要是token类型,这样才会直接返回Token,浏览器发起请求后,可以看到熟悉而又陌生的界面,没错,实际上这里就是使用我们之前讲解的SpringSecurity进行登陆,当然也可以配置一下记住我之类的功能,这里就不演示了: - -![image-20220410132507626](https://tva1.sinaimg.cn/large/e6c9d24ely1h14k666r5sj22aq0js0ue.jpg) - -但是登录之后我们发现出现了一个错误: - -![image-20220410132557704](https://tva1.sinaimg.cn/large/e6c9d24ely1h14k71g3joj21ae0a60u3.jpg) - -这是因为登录成功之后,验证服务器需要将结果给回客户端,所以需要提供客户端的回调地址,这样浏览器就会被重定向到指定的回调地址并且请求中会携带Token信息,这里我们随便配置一个回调地址: - -```java -@Override -public void configure(ClientDetailsServiceConfigurer clients) throws Exception { - clients - .inMemory() - .withClient("web") - .secret(encoder.encode("654321")) - .autoApprove(false) - .scopes("book", "user", "borrow") - .redirectUris("http://localhost:8201/login") //可以写多个,当有多个时需要在验证请求中指定使用哪个地址进行回调 - .authorizedGrantTypes("client_credentials", "password", "implicit", "authorization_code", "refresh_token"); -} -``` - -接着重启验证服务器,再次访问: - -![image-20220410132927907](https://tva1.sinaimg.cn/large/e6c9d24ely1h14kaonj56j21fw0gw40h.jpg) - -可以看到这里会让我们选择哪些范围进行授权,就像我们在微信小程序中登陆一样,会让我们授予用户信息权限、支付权限、信用查询权限等,我们可以自由决定要不要给客户端授予访问这些资源的权限,这里我们全部选择授予: - -![image-20220410133106639](https://tva1.sinaimg.cn/large/e6c9d24ely1h14kcehaeej21ye08o758.jpg) - -授予之后,可以看到浏览器被重定向到我们刚刚指定的回调地址中,并且携带了Token信息,现在我们来校验一下看看: - -![image-20220410133210183](https://tva1.sinaimg.cn/large/e6c9d24ely1h14kdhpibjj21d00e675i.jpg) - -可以看到,Token也是有效的。 - -最后我们来看看第四种最安全的授权码模式,这种模式其实流程和上面是一样的,但是请求的是code类型:http://localhost:8500/sso/oauth/authorize?client_id=web&response_type=code - -可以看到访问之后,依然会进入到回调地址,但是这时给的就是授权码了,而不是直接给Token,那么这个Token该怎么获取呢? - -![image-20220410133411554](https://tva1.sinaimg.cn/large/e6c9d24ely1h14kflls50j2178066glw.jpg) - -按照我们之前讲解的原理,我们需要携带授权码和secret一起请求,才能拿到Token,正常情况下是由回调的服务器进行处理,这里我们就在Postman中进行,我们复制刚刚得到的授权码,接口依然是`localhost:8500/sso/oauth/token`: - -![image-20220410133702534](https://tva1.sinaimg.cn/large/e6c9d24ely1h14kike171j21ba0f8404.jpg) - -可以看到结果也是正常返回了Token信息: - -![image-20220410133940312](https://tva1.sinaimg.cn/large/e6c9d24ely1h14klb6srtj21be0bmgn6.jpg) - -这样我们四种最基本的Token请求方式就实现了。 - -最后还有一个是刷新令牌使用的,当我们的Token过期时,我们就可以使用这个refresh_token来申请一个新的Token: - -![image-20220410140759967](https://tva1.sinaimg.cn/large/e6c9d24ely1h14lesf7waj21bc0giq4u.jpg) - -但是执行之后我们发现会直接出现一个内部错误: - -![image-20220410140822286](https://tva1.sinaimg.cn/large/e6c9d24ely1h14lf5oqckj217804mt8w.jpg) - -![image-20220410140838064](https://tva1.sinaimg.cn/large/e6c9d24ely1h14lffhh02j21w601qmy6.jpg) - -查看日志发现,这里还需要我们单独配置一个UserDetailsService,我们直接把Security中的实例注册为Bean: - -```java -@Bean -@Override -public UserDetailsService userDetailsServiceBean() throws Exception { - return super.userDetailsServiceBean(); -} -``` - -然后在Endpoint中设置: - -```java -@Resource -UserDetailsService service; - -@Override -public void configure(AuthorizationServerEndpointsConfigurer endpoints) { - endpoints - .userDetailsService(service) - .authenticationManager(manager); -} -``` - -最后再次尝试刷新Token: - -![image-20220410141143519](https://tva1.sinaimg.cn/large/e6c9d24ely1h14linq3lkj21bc0bumyp.jpg) - -OK,成功刷新Token,返回了一个新的。 - -### 基于@EnableOAuth2Sso实现 - -前面我们将验证服务器已经搭建完成了,现在我们就来实现一下单点登陆吧,SpringCloud为我们提供了客户端的直接实现,我们只需要添加一个注解和少量配置即可将我们的服务作为一个单点登陆应用,使用的是第四种授权码模式。 - -一句话来说就是,这种模式只是将验证方式由原本的默认登录形式改变为了统一在授权服务器登陆的形式。 - -首先还是依赖: - -```xml - - org.springframework.boot - spring-boot-starter-security - - - - org.springframework.cloud - spring-cloud-starter-oauth2 - 2.2.5.RELEASE - -``` - -我们只需要直接在启动类上添加即可: - -```java -@EnableOAuth2Sso -@SpringBootApplication -public class BookApplication { - public static void main(String[] args) { - SpringApplication.run(BookApplication.class, args); - } -} -``` - -我们不需要进行额外的配置类,因为这个注解已经帮我们做了: - -```java -@Target({ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -@Documented -@EnableOAuth2Client -@EnableConfigurationProperties({OAuth2SsoProperties.class}) -@Import({OAuth2SsoDefaultConfiguration.class, OAuth2SsoCustomConfiguration.class, ResourceServerTokenServicesConfiguration.class}) -public @interface EnableOAuth2Sso { -} -``` - -可以看到它直接注册了OAuth2SsoDefaultConfiguration,而这个类就是帮助我们对Security进行配置的: - -```java -@Configuration -@Conditional({NeedsWebSecurityCondition.class}) -public class OAuth2SsoDefaultConfiguration extends WebSecurityConfigurerAdapter { - //直接继承的WebSecurityConfigurerAdapter,帮我们把验证设置都写好了 - private final ApplicationContext applicationContext; - - public OAuth2SsoDefaultConfiguration(ApplicationContext applicationContext) { - this.applicationContext = applicationContext; - } -``` - -接着我们需要在配置文件中配置我们的验证服务器相关信息: - -```yaml -security: - oauth2: - client: - #不多说了 - client-id: web - client-secret: 654321 - #Token获取地址 - access-token-uri: http://localhost:8500/sso/oauth/token - #验证页面地址 - user-authorization-uri: http://localhost:8500/sso/oauth/authorize - resource: - #Token信息获取和校验地址 - token-info-uri: http://localhost:8500/sso/oauth/check_token -``` - -现在我们就开启图书服务,调用图书接口: - -![image-20220410144654362](https://tva1.sinaimg.cn/large/e6c9d24ely1h14mj9etfuj22730u0q8o.jpg) - -可以看到在发现没有登录验证时,会直接跳转到授权页面,进行授权登录,之后才可以继续访问图书服务: - -![image-20220410144806506](https://tva1.sinaimg.cn/large/e6c9d24ely1h14mkinbbyj219806mjs1.jpg) - -那么用户信息呢?是否也一并保存过来了?我们这里直接获取一下SpringSecurity的Context查看用户信息,获取方式跟我们之前的视频中讲解的是一样的: - -```java -@RequestMapping("/book/{bid}") -Book findBookById(@PathVariable("bid") int bid){ - //通过SecurityContextHolder将用户信息取出 - SecurityContext context = SecurityContextHolder.getContext(); - System.out.println(context.getAuthentication()); - return service.getBookById(bid); -} -``` - -再次访问图书管理接口,可以看到: - -![image-20220410145224153](https://tva1.sinaimg.cn/large/e6c9d24ely1h14moza8zuj220k01adge.jpg) - -这里使用的不是之前的UsernamePasswordAuthenticationToken也不是RememberMeAuthenticationToken,而是新的OAuth2Authentication,它保存了验证服务器的一些信息,以及经过我们之前的登陆流程之后,验证服务器发放给客户端的Token信息,并通过Token信息在验证服务器进行验证获取用户信息,最后保存到Session中,表示用户已验证,所以本质上还是要依赖浏览器存Cookie的。 - -接下来我们将所有的服务都使用这种方式进行验证,别忘了把重定向地址给所有服务都加上: - -```java -@Override -public void configure(ClientDetailsServiceConfigurer clients) throws Exception { - clients - .inMemory() - .withClient("web") - .secret(encoder.encode("654321")) - .autoApprove(true) //这里把自动审批开了,就不用再去手动选同意了 - .scopes("book", "user", "borrow") - .redirectUris("http://localhost:8101/login", "http://localhost:8201/login", "http://localhost:8301/login") - .authorizedGrantTypes("client_credentials", "password", "implicit", "authorization_code", "refresh_token"); -} -``` - -这样我们就可以实现只在验证服务器登陆,如果登陆过其他的服务都可以访问了。 - -但是我们发现一个问题,就是由于SESSION不同步,每次切换不同的服务进行访问都会重新导验证服务器去验证一次: - -![image-20220410160648326](https://tva1.sinaimg.cn/large/e6c9d24ely1h14oue66ytj212606wq42.jpg) - -这里有两个方案: - -* 像之前一样做SESSION统一存储 -* 设置context-path路径,每个服务单独设置,就不会打架了 - -但是这样依然没法解决服务间调用的问题,所以仅仅依靠单点登陆的模式不太行。 - -### 基于@EnableResourceServer实现 - -前面我们讲解了将我们的服务作为单点登陆应用直接实现单点登陆,那么现在我们如果是以第三方应用进行访问呢?这时我们就需要将我们的服务作为资源服务了,作为资源服务就不会再提供验证的过程,而是直接要求请求时携带Token,而验证过程我们这里就继续用Postman来完成,这才是我们常见的模式。 - -一句话来说,跟上面相比,我们只需要携带Token就能访问这些资源服务器了,客户端被独立了出来,用于携带Token去访问这些服务。 - -我们也只需要添加一个注解和少量配置即可: - -```java -@EnableResourceServer -@SpringBootApplication -public class BookApplication { - public static void main(String[] args) { - SpringApplication.run(BookApplication.class, args); - } -} -``` - -配置中只需要: - -```yaml -security: - oauth2: - client: - #基操 - client-id: web - client-secret: 654321 - resource: - #因为资源服务器得验证你的Token是否有访问此资源的权限以及用户信息,所以只需要一个验证地址 - token-info-uri: http://localhost:8500/sso/oauth/check_token -``` - -配置完成后,我们启动服务器,直接访问会发现: - -![image-20220411090932561](https://tva1.sinaimg.cn/large/e6c9d24ely1h15iekw720j21mw0b0mz5.jpg) - -这是由于我们的请求头中没有携带Token信息,现在有两种方式可以访问此资源: - -* 在URL后面添加`access_token`请求参数,值为Token值 -* 在请求头中添加`Authorization`,值为`Bearer +Token值` - -我们先来试试看最简的一种: - -![image-20220411091259795](https://tva1.sinaimg.cn/large/e6c9d24ely1h15ii54emij2176066wfi.jpg) - -另一种我们需要使用Postman来完成: - -![image-20220411091514501](https://tva1.sinaimg.cn/large/e6c9d24ely1h15ikgy886j21c60ikjty.jpg) - -添加验证信息后,会帮助我们转换成请求头信息: - -![image-20220411091655947](https://tva1.sinaimg.cn/large/e6c9d24ely1h15im8lcwuj21b008cwfo.jpg) - -![image-20220411091722652](https://tva1.sinaimg.cn/large/e6c9d24ely1h15imovn8jj216m056aaa.jpg) - -这样我们就将资源服务器搭建完成了。 - -我们接着来看如何对资源服务器进行深度自定义,我们可以为其编写一个配置类,比如我们现在希望用户授权了某个Scope才可以访问此服务: - -```java -@Configuration -public class ResourceConfiguration extends ResourceServerConfigurerAdapter { //继承此类进行高度自定义 - - @Override - public void configure(HttpSecurity http) throws Exception { //这里也有HttpSecurity对象,方便我们配置SpringSecurity - http - .authorizeRequests() - .anyRequest().access("#oauth2.hasScope('lbwnb')"); //添加自定义规则 - //Token必须要有我们自定义scope授权才可以访问此资源 - } -} -``` - -可以看到当没有对应的scope授权时,那么会直接返回`insufficient_scope`错误: - -![image-20220411092852367](https://tva1.sinaimg.cn/large/e6c9d24ely1h15iynrm44j21d00acjth.jpg) - -不知道各位是否有发现,实际上资源服务器完全没有必要将Security的信息保存在Session中了,因为现在只需要将Token告诉资源服务器,那么资源服务器就可以联系验证服务器,得到用户信息,就不需要使用之前的Session存储机制了,所以你会发现HttpSession中没有**SPRING_SECURITY_CONTEXT**,现在Security信息都是通过连接资源服务器获取。 - -接着我们将所有的服务都 - -但是还有一个问题没有解决,我们在使用RestTemplate进行服务间的远程调用时,会得到以下错误: - -![image-20220411115958560](https://tva1.sinaimg.cn/large/e6c9d24ely1h15nbvmhyqj21zo070n12.jpg) - -实际上这是因为在服务调用时没有携带Token信息,我们得想个办法把用户传来的Token信息在进行远程调用时也携带上,因此,我们可以直接使用OAuth2RestTemplate,它会在请求其他服务时携带当前请求的Token信息。它继承自RestTemplate,这里我们直接定义一个Bean: - -```java -@Configuration -public class WebConfiguration { - - @Resource - OAuth2ClientContext context; - - @Bean - public OAuth2RestTemplate restTemplate(){ - return new OAuth2RestTemplate(new ClientCredentialsResourceDetails(), context); - } -} -``` - -接着我们直接替换掉之前的RestTemplate即可: - -```java -@Service -public class BorrowServiceImpl implements BorrowService { - - @Resource - BorrowMapper mapper; - - @Resource - OAuth2RestTemplate template; - - @Override - public UserBorrowDetail getUserBorrowDetailByUid(int uid) { - List borrow = mapper.getBorrowsByUid(uid); - User user = template.getForObject("http://localhost:8101/user/"+uid, User.class); - //获取每一本书的详细信息 - List bookList = borrow - .stream() - .map(b -> template.getForObject("http://localhost:8201/book/"+b.getBid(), Book.class)) - .collect(Collectors.toList()); - return new UserBorrowDetail(user, bookList); - } -} -``` - -可以看到服务成功调用了: - -![image-20220411124509007](https://tva1.sinaimg.cn/large/e6c9d24ely1h15omvqe0nj21im06emyf.jpg) - -现在我们来将Nacos加入,并通过Feign实现远程调用。 - -依赖还是贴一下,不然找不到: - -```xml - - com.alibaba.cloud - spring-cloud-alibaba-dependencies - 2021.0.1.0 - pom - import - -``` - -```xml - - com.alibaba.cloud - spring-cloud-starter-alibaba-nacos-discovery - - - - org.springframework.cloud - spring-cloud-starter-loadbalancer - -``` - -所有服务都已经注册成功了: - -![image-20220411132756540](https://tva1.sinaimg.cn/large/e6c9d24ely1h15pvei9c9j220w0cgdia.jpg) - -接着我们配置一下借阅服务的负载均衡: - -```java -@Configuration -public class WebConfiguration { - - @Resource - OAuth2ClientContext context; - - @LoadBalanced //和RestTemplate一样直接添加注解就行了 - @Bean - public OAuth2RestTemplate restTemplate(){ - return new OAuth2RestTemplate(new ClientCredentialsResourceDetails(), context); - } -} -``` - -![image-20220411133819847](https://tva1.sinaimg.cn/large/e6c9d24ely1h15q67k904j21ho06a75k.jpg) - -现在我们来把它替换为Feign,老样子,两个客户端: - -```java -@FeignClient("user-service") -public interface UserClient { - - @RequestMapping("/user/{uid}") - User getUserById(@PathVariable("uid") int uid); -} -``` - -```java -@FeignClient("book-service") -public interface BookClient { - - @RequestMapping("/book/{bid}") - Book getBookById(@PathVariable("bid") int bid); -} -``` - -但是配置完成之后,又出现刚刚的问题了,OpenFeign也没有携带Token进行访问: - -![image-20220411135612728](https://tva1.sinaimg.cn/large/e6c9d24ely1h15qotlr59j21zq06s77b.jpg) - -那么怎么配置Feign携带Token访问呢?遇到这种问题直接去官方查:https://docs.spring.io/spring-cloud-openfeign/docs/current/reference/html/#oauth2-support,非常简单,两个配置就搞定: - -```yaml -feign: - oauth2: - #开启Oauth支持,这样就会在请求头中携带Token了 - enabled: true - #同时开启负载均衡支持 - load-balanced: true -``` - -重启服务器,可以看到结果OK了: - -![image-20220411143628451](https://tva1.sinaimg.cn/large/e6c9d24ely1h15rupqldmj21ko07kmyl.jpg) - -这样我们就成功将之前的三个服务作为资源服务器了,注意和我们上面的作为客户端是不同的,将服务直接作为客户端相当于只需要验证通过即可,并且还是要保存Session信息,相当于只是将登录流程换到统一的验证服务器上进行罢了。而将其作为资源服务器,那么就需要另外找客户端(可以是浏览器、小程序、App、第三方服务等)来访问,并且也是需要先进行验证然后再通过携带Token进行访问,这种模式是我们比较常见的模式。 - -### 使用jwt存储Token - -官网:https://jwt.io - -JSON Web Token令牌(JWT)是一个开放标准([RFC 7519](https://tools.ietf.org/html/rfc7519)),它定义了一种紧凑和自成一体的方式,用于在各方之间作为JSON对象安全地传输信息。这些信息可以被验证和信任,因为它是数字签名的。JWT可以使用密钥(使用**HMAC**算法)或使用**RSA**或**ECDSA**进行公钥/私钥对进行签名。 - -实际上,我们之前都是携带Token向资源服务器发起请求后,资源服务器由于不知道我们Token的用户信息,所以需要向验证服务器询问此Token的认证信息,这样才能得到Token代表的用户信息,但是各位是否考虑过,如果每次用户请求都去查询用户信息,那么在大量请求下,验证服务器的压力可能会非常的大。而使用JWT之后,Token中会直接保存用户信息,这样资源服务器就不再需要询问验证服务器,自行就可以完成解析,我们的目标是不联系验证服务器就能直接完成验证。 - -JWT令牌的格式如下: - -![image-20220412083957167](https://tva1.sinaimg.cn/large/e6c9d24ely1h16n64387aj22b10u0wjb.jpg) - -一个JWT令牌由3部分组成:标头(Header)、有效载荷(Payload)和签名(Signature)。在传输的时候,会将JWT的3部分分别进行Base64编码后用`.`进行连接形成最终需要传输的字符串。 - -* 标头:包含一些元数据信息,比如JWT签名所使用的加密算法,还有类型,这里统一都是JWT。 -* 有效载荷:包括用户名称、令牌发布时间、过期时间、JWT ID等,当然我们也可以自定义添加字段,我们的用户信息一般都在这里存放。 -* 签名:首先需要指定一个密钥,该密钥仅仅保存在服务器中,保证不能让其他用户知道。然后使用Header中指定的算法对Header和Payload进行base64加密之后的结果通过密钥计算哈希值,然后就得出一个签名哈希。这个会用于之后验证内容是否被篡改。 - -这里还是补充一下一些概念,因为很多东西都是我们之前没有接触过的: - -* **Base64:**就是包括小写字母a-z、大写字母A-Z、数字0-9、符号"+"、"/"一共64个字符的字符集(末尾还有1个或多个`=`用来凑够字节数),任何的符号都可以转换成这个字符集中的字符,这个转换过程就叫做Base64编码,编码之后会生成只包含上述64个字符的字符串。相反,如果需要原本的内容,我们也可以进行Base64解码,回到原有的样子。 - - ```java - public void test(){ - String str = "你们可能不知道只用20万赢到578万是什么概念"; - //Base64不只是可以对字符串进行编码,任何byte[]数据都可以,编码结果可以是byte[],也可以是字符串 - String encodeStr = Base64.getEncoder().encodeToString(str.getBytes()); - System.out.println("Base64编码后的字符串:"+encodeStr); - - System.out.println("解码后的字符串:"+new String(Base64.getDecoder().decode(encodeStr))); - } - ``` - - 注意Base64不是加密算法,只是一种信息的编码方式而已。 - -* **加密算法:**加密算法分为对称加密和非对称加密,其中**对称加密(Symmetric Cryptography)**比较好理解,就像一把锁配了两把钥匙一样,这两把钥匙你和别人都有一把,然后你们直接传递数据,都会把数据用锁给锁上,就算传递的途中有人把数据窃取了,也没办法解密,因为钥匙只有你和对方有,没有钥匙无法进行解密,但是这样有个问题,既然解密的关键在于钥匙本身,那么如果有人不仅窃取了数据,而且对方那边的治安也不好,于是顺手就偷走了钥匙,那你们之间发的数据不就凉凉了吗。 - - 因此,**非对称加密(Asymmetric Cryptography)**算法出现了,它并不是直接生成一把钥匙,而是生成一个公钥和一个私钥,私钥只能由你保管,而公钥交给对方或是你要发送的任何人都行,现在你需要把数据传给对方,那么就需要使用私钥进行加密,但是,这个数据只能使用对应的公钥进行解密,相反,如果对方需要给你发送数据,那么就需要用公钥进行加密,而数据只能使用私钥进行解密,这样的话就算对方的公钥被窃取,那么别人发给你的数据也没办法解密出来,因为需要私钥才能解密,而只有你才有私钥。 - - 因此,非对称加密的安全性会更高一些,包括HTTPS的隐私信息正是使用非对称加密来保障传输数据的安全(当然HTTPS并不是单纯地使用非对称加密完成的,感兴趣的可以去了解一下) - - 对称加密和非对称加密都有很多的算法,比如对称加密,就有:DES、IDEA、RC2,非对称加密有:RSA、DAS、ECC - -* **不可逆加密算法:**常见的不可逆加密算法有MD5, HMAC, SHA-1, SHA-224, SHA-256, SHA-384, 和SHA-512, 其中SHA-224、SHA-256、SHA-384,和SHA-512我们可以统称为SHA2加密算法,SHA加密算法的安全性要比MD5更高,而SHA2加密算法比SHA1的要高,其中SHA后面的数字表示的是加密后的字符串长度,SHA1默认会产生一个160位的信息摘要。经过不可逆加密算法得到的加密结果,是无法解密回去的,也就是说加密出来是什么就是什么了。本质上,其就是一种哈希函数,用于对一段信息产生摘要,以**防止被篡改**。 - - 实际上这种算法就常常被用作信息摘要计算,同样的数据通过同样的算法计算得到的结果肯定也一样,而如果数据被修改,那么计算的结果肯定就不一样了。 - -这里我们就可以利用jwt,将我们的Token采用新的方式进行存储: - -![image-20220412113124880](https://tva1.sinaimg.cn/large/e6c9d24ely1h16s4h08b9j21ku0dumz4.jpg) - -这里我们使用最简单的一种方式,对称密钥,我们需要对验证服务器进行一些修改: - -```java -@Bean -public JwtAccessTokenConverter tokenConverter(){ //Token转换器,将其转换为JWT - JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); - converter.setSigningKey("lbwnb"); //这个是对称密钥,一会资源服务器那边也要指定为这个 - return converter; -} - -@Bean -public TokenStore tokenStore(JwtAccessTokenConverter converter){ //Token存储方式现在改为JWT存储 - return new JwtTokenStore(converter); //传入刚刚定义好的转换器 -} -``` - -```java -@Resource -TokenStore store; - -@Resource -JwtAccessTokenConverter converter; - -private AuthorizationServerTokenServices serverTokenServices(){ //这里对AuthorizationServerTokenServices进行一下配置 - DefaultTokenServices services = new DefaultTokenServices(); - services.setSupportRefreshToken(true); //允许Token刷新 - services.setTokenStore(store); //添加刚刚的TokenStore - services.setTokenEnhancer(converter); //添加Token增强,其实就是JwtAccessTokenConverter,增强是添加一些自定义的数据到JWT中 - return services; -} - -@Override -public void configure(AuthorizationServerEndpointsConfigurer endpoints) { - endpoints - .tokenServices(serverTokenServices()) //设定为刚刚配置好的AuthorizationServerTokenServices - .userDetailsService(service) - .authenticationManager(manager); -} -``` - -然后我们就可以重启验证服务器了: - -![image-20220412115919019](https://tva1.sinaimg.cn/large/e6c9d24ely1h16sxi8zk7j21c20dkdjy.jpg) - -可以看到成功获取了AccessToken,但是这里的格式跟我们之前的格式就大不相同了,因为现在它是JWT令牌,我们可以对其进行一下Base64解码: - -![image-20220412120136453](https://tva1.sinaimg.cn/large/e6c9d24ely1h16szvyek8j22om0dsmzi.jpg) - -可以看到所有的验证信息包含在内,现在我们对资源服务器进行配置: - -```yaml -security: - oauth2: - resource: - jwt: - key-value: lbwnb #注意这里要跟验证服务器的密钥一致,这样算出来的签名才会一致 -``` - -然后启动资源服务器,请求一下接口试试看: - -![image-20220412120350331](https://tva1.sinaimg.cn/large/e6c9d24ely1h16t27q8g1j21b60foacd.jpg) - -请求成功,得到数据: - -![image-20220412120417005](https://tva1.sinaimg.cn/large/e6c9d24ely1h16t2o2neej21ca08074t.jpg) - -注意如果Token有误,那么会得到: - -![image-20220412120441794](https://tva1.sinaimg.cn/large/e6c9d24ely1h16t33dv9kj215m04uaab.jpg) - -*** - -## Redis与分布式 - -在SpringBoot阶段,我们学习了Redis,它是一个基于内存的高性能数据库,我们当时已经学习了包括基本操作、常用数据类型、持久化、事务和锁机制以及使用Java与Redis进行交互等,利用它的高性能,我们还使用它来做Mybatis的二级缓存、以及Token的持久化存储。而这一部分,我们将继续深入,探讨Redis在分布式开发场景下的应用。 - -### 主从复制 - -在分布式场景下,我们可以考虑让Redis实现主从模式: - -![image-20220412161901616](https://tva1.sinaimg.cn/large/e6c9d24ely1h170fqh9k0j21ao07gwf9.jpg) - -主从复制,是指将一台Redis服务器的数据,复制到其他的Redis服务器。前者称为主节点(Master),后者称为从节点(Slave),数据的复制是单向的,只能由主节点到从节点。Master以写为主,Slave 以读为主。 - -这样的好处肯定是显而易见的: - -* 实现了读写分离,提高了性能。 -* 在写少读多的场景下,我们甚至可以安排很多个从节点,这样就能够大幅度的分担压力,并且就算挂掉一个,其他的也能使用。 - -那么我们现在就来尝试实现一下,这里我们还是在Windows下进行测试,打开Redis文件夹,我们要开启两个Redis服务器,修改配置文件`redis.windows.conf`: - -```conf -# Accept connections on the specified port, default is 6379 (IANA #815344). -# If port 0 is specified Redis will not listen on a TCP socket. -port 6001 -``` - -一个服务器的端口设定为6001,复制一份,另一个的端口为6002,接着我们指定配置文件进行启动,打开cmd: - -![image-20220413115227344](https://tva1.sinaimg.cn/large/e6c9d24ely1h17ycpqc7aj21ho0iytcq.jpg) - -现在我们的两个服务器就启动成功了,接着我们可以使用命令查看当前服务器的主从状态,我们打开客户端: - -![image-20220413115458597](https://tva1.sinaimg.cn/large/e6c9d24ely1h17yfaen55j21ge0d6dj4.jpg) - -输入`info replication`命令来查看当前的主从状态,可以看到默认的角色为:master,也就是说所有的服务器在启动之后都是主节点的状态。那么现在我们希望让6002作为从节点,通过一个命令即可: - -![image-20220413115722765](https://tva1.sinaimg.cn/large/e6c9d24ely1h17yhstlr6j21hk0k078p.jpg) - -可以看到,在输入`replicaof 127.0.0.1 6001`命令后,就会将6001服务器作为主节点,而当前节点作为6001的从节点,并且角色也会变成:slave,接着我们来看看6001的情况: - -![image-20220413115959759](https://tva1.sinaimg.cn/large/e6c9d24ely1h17ykiln90j21cq0c4tbw.jpg) - -可以看到从节点信息中已经出现了6002服务器,也就是说现在我们的6001和6002就形成了主从关系(还包含一个偏移量,这个偏移量反应的是从节点的同步情况) - -> 主服务器和从服务器都会维护一个复制偏移量,主服务器每次向从服务器中传递 N 个字节的时候,会将自己的复制偏移量加上 N。从服务器中收到主服务器的 N 个字节的数据,就会将自己额复制偏移量加上 N,通过主从服务器的偏移量对比可以很清楚的知道主从服务器的数据是否处于一致,如果不一致就需要进行增量同步了。 - -那么我们现在可以来测试一下,在主节点新增数据,看看是否会同步到从节点: - -![image-20220413120133934](https://tva1.sinaimg.cn/large/e6c9d24ely1h17ym5549oj219w06kjs4.jpg) - -可以看到,我们在6001服务器插入的`a`,可以在从节点6002读取到,那么,从节点新增的数据在主节点能得到吗?我们来测试一下: - -![image-20220413120235138](https://tva1.sinaimg.cn/large/e6c9d24ely1h17yn7gu6zj20xy0323z1.jpg) - -可以看到,从节点压根就没办法进行数据插入,节点的模式为只读模式。那么如果我们现在不想让6002作为6001的从节点了呢? - -![image-20220413124021549](https://tva1.sinaimg.cn/large/e6c9d24ely1h17zqijbfmj21bu09s76d.jpg) - -可以看到,通过输入`replicaof no one`,即可变回Master角色。接着我们再来启动一台6003服务器,流程是一样的: - -![image-20220413121338391](https://tva1.sinaimg.cn/large/e6c9d24ely1h17yypexpij215c062gmg.jpg) - -可以看到,在连接之后,也会直接同步主节点的数据,因此无论是已经处于从节点状态还是刚刚启动完成的服务器,都会从主节点同步数据,实际上整个同步流程为: - -1. 从节点执行replicaof ip port命令后,从节点会保存主节点相关的地址信息。 -2. 从节点通过每秒运行的定时任务发现配置了新的主节点后,会尝试与该节点建立网络连接,专门用于接收主节点发送的复制命令。 -3. 连接成功后,第一次会将主节点的数据进行全量复制,之后采用增量复制,持续将新来的写命令同步给从节点。 - -当我们的主节点关闭后,从节点依然可以读取数据: - -![image-20220413122411006](https://tva1.sinaimg.cn/large/e6c9d24ely1h17z9oo7bdj21ce0majw5.jpg) - -但是从节点会疯狂报错: - -![image-20220413122528096](https://tva1.sinaimg.cn/large/e6c9d24ely1h17zb0v187j21he032myo.jpg) - -当然每次都去敲个命令配置主从太麻烦了,我们可以直接在配置文件中配置,添加这样行即可: - -``` -replicaof 127.0.0.1 6001 -``` - -这里我们给6002和6003服务器都配置一下,现在我们重启三个服务器。 - -![image-20220413122848685](https://tva1.sinaimg.cn/large/e6c9d24ely1h17zehyh4pj21hg0d6gu1.jpg) - -当然,除了作为Master节点的从节点外,我们还可以将其作为从节点的从节点,比如现在我们让6003作为6002的从节点: - -![image-20220413123047711](https://tva1.sinaimg.cn/large/e6c9d24ely1h17zgkb251j216e09mq4n.jpg) - -也就是说,现在差不多是这样的的一个情况: - -![image-20220413123603650](https://tva1.sinaimg.cn/large/e6c9d24ely1h17zm1h2wpj21ja0ikjv0.jpg) - -采用这种方式,优点肯定是显而易见的,但是缺点也很明显,整个传播链路一旦中途出现问题,那么就会导致后面的从节点无法及时同步。 - -### 哨兵模式 - -前面我们讲解了Redis实现主从复制的一些基本操作,那么我们接着来看哨兵模式。 - -经过之前的学习,我们发现,实际上最关键的还是主节点,因为一旦主节点出现问题,那么整个主从系统将无法写入,因此,我们得想一个办法,处理一下主节点故障的情况。实际上我们可以参考之前的服务治理模式,比如Nacos和Eureka,所有的服务都会被实时监控,那么只要出现问题,肯定是可以及时发现的,并且能够采取响应的补救措施,这就是我们即将介绍的哨兵: - -![image-20220413154102800](https://tva1.sinaimg.cn/large/e6c9d24ely1h184yisdi6j218i0dk405.jpg) - -注意这里的哨兵不是我们之前学习SpringCloud Alibaba的那个,是专用于Redis的。哨兵会对所有的节点进行监控,如果发现主节点出现问题,那么会立即让从节点进行投票,选举一个新的主节点出来,这样就不会由于主节点的故障导致整个系统不可写(注意要实现这样的功能最小的系统必须是一主一从,再小的话就没有意义了) - -![image-20220413155459399](https://tva1.sinaimg.cn/large/e6c9d24ely1h185d16octj21940dawg5.jpg) - -那么怎么启动一个哨兵呢?我们只需要稍微修改一下配置文件即可,这里直接删除全部内容,添加: - -``` -sentinel monitor lbwnb 127.0.0.1 6001 1 -``` - -其中第一个和第二个是固定,第三个是为监控对象名称,随意,后面就是主节点的相关信息,包括IP地址和端口,最后一个1我们暂时先不说,然后我们使用此配置文件启动服务器,可以看到启动后: - -![image-20220413161154185](https://tva1.sinaimg.cn/large/e6c9d24ely1h185umqg2rj21fg0maq8l.jpg) - -![image-20220413161306103](https://tva1.sinaimg.cn/large/e6c9d24ely1h185vv5uuvj217e03u40p.jpg) - -可以看到以哨兵模式启动后,会自动监控主节点,然后还会显示那些节点是作为从节点存在的。 - -现在我们直接把主节点关闭,看看会发生什么事情: - -![image-20220413161730035](https://tva1.sinaimg.cn/large/e6c9d24ely1h1860g13toj21hg072n1k.jpg) - -可以看到从节点还是正常的在报错,一开始的时候不会直接重新进行选举而是继续尝试重连(因为有可能只是网络小卡一下,没必要这么敏感),但是我们发现,经过一段时间之后,依然无法连接,哨兵输出了以下内容: - -![image-20220413161843439](https://tva1.sinaimg.cn/large/e6c9d24ely1h1861pyd5uj21gs0jman3.jpg) - -可以看到哨兵发现主节点已经有一段时间不可用了,那么就会开始进行重新选举,6003节点被选为了新的主节点,并且之前的主节点6001变成了新的主节点的从节点: - -![image-20220413162259056](https://tva1.sinaimg.cn/large/e6c9d24ely1h18665ksokj21hq05sjuu.jpg) - -![image-20220413162310821](https://tva1.sinaimg.cn/large/e6c9d24ely1h1866crze4j21f207kq5f.jpg) - -当我们再次启动6001时,会发现,它自动变成了6003的从节点,并且会将数据同步过来: - -![image-20220413163527235](https://tva1.sinaimg.cn/large/e6c9d24ely1h186j4ul8mj21gu09qq8y.jpg) - -那么,这个选举规则是怎样的呢?是在所有的从节点中随机选取还是遵循某种规则呢? - -1. 首先会根据优先级进行选择,可以在配置文件中进行配置,添加`replica-priority`配置项(默认是100),越小表示优先级越高。 -2. 如果优先级一样,那就选择偏移量最大的 -3. 要是还选不出来,那就选择runid(启动时随机生成的)最小的。 - -要是哨兵也挂了咋办?没事,咱们可以多安排几个哨兵,只需要把哨兵的配置复制一下,然后修改端口,这样就可以同时启动多个哨兵了,我们启动3个哨兵(一主二从三哨兵),这里我们吧最后一个值改为`2`: - -``` -sentinel monitor lbwnb 192.168.0.8 6001 2 -``` - -这个值实际上代表的是当有几个哨兵认为主节点挂掉时,就判断主节点真的挂掉了 - -![image-20220413201201051](https://tva1.sinaimg.cn/large/e6c9d24ely1h18csiah4ej21e60b8794.jpg) - -现在我们把6001节点挂掉,看看这三个哨兵会怎么样: - -![image-20220413203351360](https://tva1.sinaimg.cn/large/e6c9d24ely1h18df6teykj21h60fk7d0.jpg) - -可以看到都显示将master切换为6002节点了。 - -那么,在哨兵重新选举新的主节点之后,我们Java中的Redis的客户端怎么感知到呢?我们来看看,首先还是导入依赖: - -```xml - - - redis.clients - jedis - 4.2.1 - - -``` - -```java -public class Main { - public static void main(String[] args) { - //这里我们直接使用JedisSentinelPool来获取Master节点 - //需要把三个哨兵的地址都填入 - try (JedisSentinelPool pool = new JedisSentinelPool("lbwnb", - new HashSet<>(Arrays.asList("192.168.0.8:26741", "192.168.0.8:26740", "192.168.0.8:26739")))) { - Jedis jedis = pool.getResource(); //直接询问并得到Jedis对象,这就是连接的Master节点 - jedis.set("test", "114514"); //直接写入即可,实际上就是向Master节点写入 - - Jedis jedis2 = pool.getResource(); //再次获取 - System.out.println(jedis2.get("test")); //读取操作 - } catch (Exception e) { - e.printStackTrace(); - } - } -} -``` - -这样,Jedis对象就可以通过哨兵来获取,当Master节点更新后,也能得到最新的。 - -### 集群搭建 - -如果我们服务器的内存不够用了,但是现在我们的Redis又需要继续存储内容,那么这个时候就可以利用集群来实现扩容。 - -因为单机的内存容量最大就那么多,已经没办法再继续扩展了,但是现在又需要存储更多的内容,这时我们就可以让N台机器上的Redis来分别存储各个部分的数据(每个Redis可以存储1/N的数据量),这样就实现了容量的横向扩展。同时每台Redis还可以配一个从节点,这样就可以更好地保证数据的安全性。 - -![image-20220413211725149](https://tva1.sinaimg.cn/large/e6c9d24ely1h18eoii8ttj21c60homz7.jpg) - -那么问题来,现在用户来了一个写入的请求,数据该写到哪个节点上呢?我们来研究一下集群的机制: - -首先,一个Redis集群包含16384个插槽,集群中的每个Redis 实例负责维护一部分插槽以及插槽所映射的键值数据,那么这个插槽是什么意思呢? - -实际上,插槽就是键的Hash计算后的一个结果,注意这里出现了`计算机网络`中的CRC循环冗余校验,这里采用CRC16,能得到16个bit位的数据,也就是说算出来之后结果是0-65535之间,再进行取模,得到最终结果: - -**Redis key的路由计算公式:slot = CRC16(key) % 16384** - -结果的值是多少,就应该存放到对应维护的Redis下,比如Redis节点1负责0-25565的插槽,而这时客户端插入了一个新的数据`a=10`,a在Hash计算后结果为666,那么a就应该存放到1号Redis节点中。简而言之,本质上就是通过哈希算法将插入的数据分摊到各个节点的,所以说哈希算法真的是处处都有用啊。 - -那么现在我们就来搭建一个简单的Redis集群,这里创建6个配置,注意开启集群模式: - -``` -# Normal Redis instances can't be part of a Redis Cluster; only nodes that are -# started as cluster nodes can. In order to start a Redis instance as a -# cluster node enable the cluster support uncommenting the following: -# -cluster-enabled yes -``` - -接着记得把所有的持久化文件全部删除,所有的节点内容必须是空的。 - -然后输入`redis-cli.exe --cluster create --cluster-replicas 1 127.0.0.1:6001 127.0.0.1:6002 127.0.0.1:6003 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003`,这里的`--cluster-replicas 1`指的是每个节点配一个从节点: - -![image-20220413215928157](https://tva1.sinaimg.cn/large/e6c9d24ely1h18fw9o0isj21hc0mw12l.jpg) - -输入之后,会为你展示客户端默认分配的方案,并且会询问你当前的方案是否合理。可以看到6001/6002/6003都被选为主节点,其他的为从节点,我们直接输入yes即可: - -![image-20220413215243309](https://tva1.sinaimg.cn/large/e6c9d24ely1h18fp8r1m9j21h605sabp.jpg) - -最后分配成功,可以看到插槽的分配情况: - -![image-20220413215958201](https://tva1.sinaimg.cn/large/e6c9d24ely1h18fwsc8ecj21go0li467.jpg) - -现在我们随便连接一个节点,尝试插入一个值: - -![image-20220413220609800](https://tva1.sinaimg.cn/large/e6c9d24ely1h18g381vqlj2122030dgk.jpg) - -在插入时,出现了一个错误,实际上这就是因为a计算出来的哈希值(插槽),不归当前节点管,我们得去管这个插槽的节点执行,通过上面的分配情况,我们可以得到15495属于节点6003管理: - -![image-20220413220811536](https://tva1.sinaimg.cn/large/e6c9d24ely1h18g5ccyqpj20y205e3z8.jpg) - -在6003节点插入成功,当然我们也可以使用集群方式连接,这样我们无论在哪个节点都可以插入,只需要添加`-c`表示以集群模式访问: - -![image-20220413220935678](https://tva1.sinaimg.cn/large/e6c9d24ely1h18g6stccaj216y04uwfk.jpg) - -可以看到,在6001节点成功对a的值进行了更新,只不过还是被重定向到了6003节点进行插入。 - -我们可以输入`cluster nodes`命令来查看当前所有节点的信息: - -![image-20220413222104845](https://tva1.sinaimg.cn/large/e6c9d24ely1h18gir6s5mj21hk09mgs0.jpg) - -那么现在如果我们让某一个主节点挂掉会怎么样?现在我们把6001挂掉: - -![image-20220413223237962](https://tva1.sinaimg.cn/large/e6c9d24ely1h18gurtemjj21h808wwkx.jpg) - -可以看到原本的6001从节点7001,晋升为了新的主节点,而之前的6001已经挂了,现在我们将6001重启试试看: - -![image-20220413223337494](https://tva1.sinaimg.cn/large/e6c9d24ely1h18gvstwbkj21hk04e76h.jpg) - -可以看到6001变成了7001的从节点,那么要是6001和7001都挂了呢? - -![image-20220413223702884](https://tva1.sinaimg.cn/large/e6c9d24ely1h18gzd1jxzj21ha0460v0.jpg) - -这时我们尝试插入新的数据: - -![image-20220413223724440](https://tva1.sinaimg.cn/large/e6c9d24ely1h18gzqibkqj20va03a74o.jpg) - -可以看到,当存在节点不可用时,会无法插入新的数据,现在我们将6001和7001恢复: - -![image-20220413223813370](https://tva1.sinaimg.cn/large/e6c9d24ely1h18h0l5pcnj21gs07a77z.jpg) - -可以看到恢复之后又可以继续正常使用了。 - -最后我们来看一下如何使用Java连接到集群模式下的Redis,我们需要用到JedisCluster对象: - -```java -public class Main { - public static void main(String[] args) { - //和客户端一样,随便连一个就行,也可以多写几个,构造方法有很多种可以选择 - try(JedisCluster cluster = new JedisCluster(new HostAndPort("192.168.0.8", 6003))){ - System.out.println("集群实例数量:"+cluster.getClusterNodes().size()); - cluster.set("a", "yyds"); - System.out.println(cluster.get("a")); - } - } -} -``` - -操作基本和Jedis对象一样,这里就不多做赘述了。 - -### 分布式锁 - -在我们的传统单体应用中,经常会用到锁机制,目的是为了防止多线程竞争导致的并发问题,但是现在我们在分布式环境下,又该如何实现锁机制呢?可能一条链路上有很多的应用,它们都是独立运行的,这时我们就可以借助Redis来实现分布式锁。 - -还记得我们上一章最后提出的问题吗? - -```java -@Override -public boolean doBorrow(int uid, int bid) { - //1. 判断图书和用户是否都支持借阅,如果此时来了10个线程,都进来了,那么都能够判断为可以借阅 - if(bookClient.bookRemain(bid) < 1) - throw new RuntimeException("图书数量不足"); - if(userClient.userRemain(uid) < 1) - throw new RuntimeException("用户借阅量不足"); - //2. 首先将图书的数量-1,由于上面10个线程同时进来,同时判断可以借阅,那么这个10个线程就同时将图书数量-1,那库存岂不是直接变成负数了??? - if(!bookClient.bookBorrow(bid)) - throw new RuntimeException("在借阅图书时出现错误!"); - ... -} -``` - -实际上在高并发下,我们看似正常的借阅流程,会出现问题,比如现在同时来了10个同学要借同一本书,但是现在只有3本,而我们的判断规则是,首先看书够不够,如果此时这10个请求都已经走到这里,并且都判定为可以进行借阅,那么问题就出现了,接下来这10个请求都开始进行借阅操作,导致库存直接爆表,形成超借问题(在电商系统中也存在同样的超卖问题) - -因此,为了解决这种问题,我们就可以利用分布式锁来实现。那么Redis如何去实现分布式锁呢? - -在Redis存在这样一个命令: - -``` -setnx key value -``` - -这个命令看起来和`set`命令差不多,但是它有一个机制,就是只有当指定的key不存在的时候,才能进行插入,实际上就是`set if not exists`的缩写。 - -![image-20220414105646460](https://tva1.sinaimg.cn/large/e6c9d24ely1h192d1jdakj214q07uab5.jpg) - -可以看到,当客户端1设定a之后,客户端2使用`setnx`会直接失败。 - -![image-20220414105854959](https://tva1.sinaimg.cn/large/e6c9d24ely1h192f9yztfj211g08i75i.jpg) - -当客户端1将a删除之后,客户端2就可以使用`setnx`成功插入了。 - -利用这种特性,我们就可以在不同的服务中实现分布式锁,那么问题来了,要是某个服务加了锁但是卡顿了呢,或是直接崩溃了,那这把锁岂不是永远无法释放了?因此我们还可以考虑加个过期时间: - -``` -set a 666 EX 5 NX -``` - -这里使用`set`命令,最后加一个NX表示是使用`setnx`的模式,和上面是一样的,但是可以通过EX设定过期时间,这里设置为5秒,也就是说如果5秒还没释放,那么就自动删除。 - -![image-20220414111008456](https://tva1.sinaimg.cn/large/e6c9d24ely1h192qy0x9pj213w09ymyq.jpg) - -当然,添加了过期时间,带了的好处是显而易见的,但是同时也带来了很多的麻烦,我们来设想一下这种情况: - -![image-20220414112359738](https://tva1.sinaimg.cn/large/e6c9d24ely1h1935d9f0jj21im0cydi8.jpg) - -因此,单纯只是添加过期时间,会出现这种把别人加的锁谁卸了的情况,要解决这种问题也很简单,我们现在的目标就是保证任务只能删除自己加的锁,如果是别人加的锁是没有资格删的,所以我们可以吧a的值指定为我们任务专属的值,比如可以使用UUID之类的,如果在主动删除锁的时候发现值不是我们当前任务指定的,那么说明可能是因为超时,其他任务已经加锁了。 - -![image-20220414113041835](https://tva1.sinaimg.cn/large/e6c9d24ely1h193cc7opzj21hy0da40t.jpg) - -如果你在学习本篇之前完成了JUC并发编程篇的学习,那么一定会有一个疑惑,如果在超时之前那一刹那进入到释放锁的阶段,获取到值肯定还是自己,但是在即将执行删除之前,由于超时机制导致被删除并且其他任务也加锁了,那么这时再进行删除,仍然会导致删除其他任务加的锁。 - -![image-20220414113709773](https://tva1.sinaimg.cn/large/e6c9d24ely1h193j2b68fj21mw0d6mzv.jpg) - -实际上本质还是因为锁的超时时间不太好衡量,如果超时时间能够设定地比较恰当,那么就可以避免这种问题了。 - -要解决这个问题,我们可以借助一下Redisson框架,它是Redis官方推荐的Java版的Redis客户端。它提供的功能非常多,也非常强大,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期,它为我们提供了很多种分布式锁的实现,使用起来也类似我们在JUC中学习的锁,这里我们尝试使用一下它的分布式锁功能。 - -```xml - - org.redisson - redisson - 3.17.0 - - - - io.netty - netty-all - 4.1.75.Final - -``` - -首先我们来看看不加锁的情况下: - -```java -public static void main(String[] args) { - for (int i = 0; i < 10; i++) { - new Thread(() -> { - try(Jedis jedis = new Jedis("192.168.0.10", 6379)){ - for (int j = 0; j < 100; j++) { //每个客户端获取a然后增加a的值再写回去,如果不加锁那么肯定会出问题 - int a = Integer.parseInt(jedis.get("a")) + 1; - jedis.set("a", a+""); - } - } - }).start(); - } -} -``` - -这里没有直接用`incr`而是我们自己进行计算,方便模拟,可以看到运行结束之后a的值并不是我们想要的: - -![image-20220414133258227](https://tva1.sinaimg.cn/large/e6c9d24ely1h196vkaykrj21ce022dft.jpg) - -现在我们来给它加一把锁,注意这个锁是基于Redis的,不仅仅只可以用于当前应用,是能够垮系统的: - -```java -public static void main(String[] args) { - Config config = new Config(); - config.useSingleServer().setAddress("redis://192.168.0.10:6379"); //配置连接的Redis服务器,也可以指定集群 - RedissonClient client = Redisson.create(config); //创建RedissonClient客户端 - for (int i = 0; i < 10; i++) { - new Thread(() -> { - try(Jedis jedis = new Jedis("192.168.0.10", 6379)){ - RLock lock = client.getLock("testLock"); //指定锁的名称,拿到锁对象 - for (int j = 0; j < 100; j++) { - lock.lock(); //加锁 - int a = Integer.parseInt(jedis.get("a")) + 1; - jedis.set("a", a+""); - lock.unlock(); //解锁 - } - } - System.out.println("结束!"); - }).start(); - } -} -``` - -可以看到结果没有问题: - -![image-20220414133403403](https://tva1.sinaimg.cn/large/e6c9d24ely1h196wp13naj215202caa3.jpg) - -注意,如果用于存放锁的Redis服务器挂了,那么肯定是会出问题的,这个时候我们就可以使用RedLock,它的思路是,在多个Redis服务器上保存锁,只需要超过半数的Redis服务器获取到锁,那么就真的获取到锁了,这样就算挂掉一部分节点,也能保证正常运行,这里就不做演示了。 - -*** - -## MySQL与分布式 - -前面我讲解了Redis在分布式场景的下的相关应用,接着我们来看看MySQL数据库在分布式场景下的应用。 - -### 主从复制 - -当我们使用MySQL的时候,也可以采取主从复制的策略,它的实现思路基本和Redis相似,也是采用增量复制的方式,MySQL会在运行的过程中,会记录二进制日志,所有的DML和DDL操作都会被记录进日志中,主库只需要将记录的操作复制给从库,让从库也运行一次,那么就可以实现主从复制。但是注意它不会在一开始进行全量复制,所以最好再开始主从之前将数据库的内容保持一致。 - -和之前一样,一旦我们实现了主从复制,那么就算主库出现故障,从库也能正常提供服务,并且还可以实现读写分离等操作。这里我们就使用两台主机来搭建一主一从的环境,首先确保两台服务器都安装了MySQL数据库并且都已经正常运行了: - -![image-20220414162319865](https://tva1.sinaimg.cn/large/e6c9d24ely1h19bsu4pv1j21zq0dyn3r.jpg) - -接着我们需要创建对应的账号,一会方便从库进行访问的用户: - -```sql -CREATE USER test identified with mysql_native_password by '123456'; -``` - -接着我们开启一下外网访问: - -```sh -sudo vim /etc/mysql/mysql.conf.d/mysqld.cnf -``` - -修改配置文件: - -```properties -# If MySQL is running as a replication slave, this should be -# changed. Ref https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_tmpdir -# tmpdir = /tmp -# -# Instead of skip-networking the default is now to listen only on -# localhost which is more compatible and is not less secure. -# bind-address = 127.0.0.1 这里注释掉就行 -``` - -现在我们重启一下MySQL服务: - -```sh -sudo systemctl restart mysql.service -``` - -现在我们首先来配置主库,主库只需要为我们刚刚创建好的用户分配一个主从复制的权限即可: - -```sql -grant replication slave on *.* to test; -FLUSH PRIVILEGES; -``` - -然后我们可以输入命令来查看主库的相关情况: - -![image-20220414164943974](https://tva1.sinaimg.cn/large/e6c9d24ely1h19ckapbrsj218206s3zi.jpg) - -这样主库就搭建完成了,接着我们需要将从库进行配置,首先是配置文件: - -```properties -# The following can be used as easy to replay backup logs or for replication. -# note: if you are setting up a replication slave, see README.Debian about -# other settings you may need to change. -# 这里需要将server-id配置为其他的值(默认是1)所有Mysql主从实例的id必须唯一,不能打架,不然一会开启会失败 -server-id = 2 -``` - -进入数据库,输入: - -```sql -change replication source to SOURCE_HOST='192.168.0.8',SOURCE_USER='test',SOURCE_PASSWORD='123456',SOURCE_LOG_FILE='binlog.000004',SOURCE_LOG_POS=591; -``` - -注意后面的logfile和pos就是我们上面从主库中显示的信息。 - -![image-20220414170022303](https://tva1.sinaimg.cn/large/e6c9d24ely1h19cvd6eypj218m032my0.jpg) - -执行完成后,显示OK表示没有问题,接着输入: - -```sql -start replica; -``` - -现在我们的从机就正式启动了,现在我们输入: - -```sql -show replica status\G; -``` - -来查看当前从机状态,可以看到: - -![image-20220414192045320](https://tva1.sinaimg.cn/large/e6c9d24ely1h19gxft4iwj217i0d4jtm.jpg) - -最关键的是下面的Replica_IO_Running和Replica_SQL_Running必须同时为Yes才可以,实际上从库会创建两个线程,一个线程负责与主库进行通信,获取二进制日志,暂时存放到一个中间表(Relay_Log)中,而另一个线程则是将中间表保存的二进制日志的信息进行执行,然后插入到从库中。 - -最后配置完成,我们来看看在主库进行操作会不会同步到从库: - -![image-20220414192508849](https://tva1.sinaimg.cn/large/e6c9d24ely1h19h202nr3j21e00aimyl.jpg) - -可以看到在主库中创建的数据库,被同步到从库中了,我们再来试试看创建表和插入数据: - -```sql -use yyds; -create table test ( - `id` int primary key, - `name` varchar(255) NULL, - `passwd` varchar(255) NULL -); -``` - -![image-20220414192829536](https://tva1.sinaimg.cn/large/e6c9d24ely1h19h5hpocfj21dg0e8tbe.jpg) - -现在我们随便插入一点数据: - -![image-20220414192920277](https://tva1.sinaimg.cn/large/e6c9d24ely1h19h6cxnxzj215c08s3zn.jpg) - -这样,我们的MySQL主从就搭建完成了,那么如果主机此时挂了会怎么样? - -![image-20220414200140191](https://tva1.sinaimg.cn/large/e6c9d24ely1h19i41l2flj211k03oq3b.jpg) - -可以看到IO线程是处于重连状态,会等待主库重新恢复运行。 - -### 分库分表 - -在大型的互联网系统中,可能单台MySQL的存储容量无法满足业务的需求,这时候就需要进行扩容了。 - -和之前的问题一样,单台主机的硬件资源是存在瓶颈的,不可能无限制地纵向扩展,这时我们就得通过多台实例来进行容量的横向扩容,我们可以将数据分散存储,让多台主机共同来保存数据。 - -那么问题来了,怎么个分散法? - -* **垂直拆分:**我们的表和数据库都可以进行垂直拆分,所谓垂直拆分,就是将数据库中所有的表,按照业务功能拆分到各个数据库中(是不是感觉跟前面两章的学习的架构对应起来了)而对于一张表,也可以通过外键之类的机制,将其拆分为多个表。 - - ![image-20220414204703883](https://tva1.sinaimg.cn/large/e6c9d24ely1h19jf8adocj21e60gyacl.jpg) - -* **水平拆分:**水平拆分针对的不是表,而是数据,我们可以让很多个具有相同表的数据库存放一部分数据,相当于是将数据分散存储在各个节点上。 - - ![image-20220414205222383](https://tva1.sinaimg.cn/large/e6c9d24ely1h19jkrb2pkj21g80eu76c.jpg) - -那么要实现这样的拆分操作,我们自行去编写代码工作量肯定是比较大的,因此目前实际上已经有一些解决方案了,比如我们可以使用MyCat(也是一个数据库中间件,相当于挂了一层代理,再通过MyCat进行分库分表操作数据库,只需要连接就能使用,类似的还有ShardingSphere-Proxy)或是Sharding JDBC(应用程序中直接对SQL语句进行分析,然后转换成分库分表操作,需要我们自己编写一些逻辑代码),这里我们就讲解一下Sharding JDBC。 - -### Sharding JDBC - -![image-20220414214856875](https://tva1.sinaimg.cn/large/e6c9d24ely1h19l7msxooj21ps0iego7.jpg) - -**官方文档(中文):**https://shardingsphere.apache.org/document/5.1.0/cn/overview/#shardingsphere-jdbc - -定位为轻量级 Java 框架,在 Java 的 JDBC 层提供的额外服务,它使用客户端直连数据库,以 jar 包形式提供服务,无需额外部署和依赖,可理解为增强版的 JDBC 驱动,完全兼容 JDBC 和各种 ORM 框架。 - -- 适用于任何基于 JDBC 的 ORM 框架,如:JPA, Hibernate, Mybatis, Spring JDBC Template 或直接使用 JDBC; -- 支持任何第三方的数据库连接池,如:DBCP, C3P0, BoneCP, HikariCP 等; -- 支持任意实现 JDBC 规范的数据库,目前支持 MySQL,PostgreSQL,Oracle,SQLServer 以及任何可使用 JDBC 访问的数据库。 - -这里我们主要演示一下水平分表方式,我们直接创建一个新的SpringBoot项目即可,依赖如下: - -```xml - - - - org.apache.shardingsphere - shardingsphere-jdbc-core-spring-boot-starter - 5.1.0 - - - - org.mybatis.spring.boot - mybatis-spring-boot-starter - 2.2.2 - - - - org.projectlombok - lombok - true - - - org.springframework.boot - spring-boot-starter-test - test - - -``` - -数据库我们这里直接用上节课的即可,因为只需要两个表结构一样的数据库即可,正好上节课进行了同步,所以我们直接把从库变回正常状态就可以了: - -```sql -stop replica; -``` - -接着我们把两个表的root用户密码改一下,一会用这个用户连接数据库: - -```sql -update user set authentication_string='' where user='root'; -update user set host = '%' where user = 'root'; -alter user root identified with mysql_native_password by '123456'; -FLUSH PRIVILEGES; -``` - -接着我们来看,如果直接尝试开启服务器,那肯定是开不了的,因为我们要配置数据源: - -![image-20220414212443482](https://tva1.sinaimg.cn/large/e6c9d24ely1h19kif2dh4j21sk08en0j.jpg) - -那么数据源该怎么配置呢?现在我们是一个分库分表的状态,需要配置两个数据源: - -```yaml -spring: - shardingsphere: - datasource: - # 有几个数据就配几个,这里是名称,按照下面的格式,名称+数字的形式 - names: db0,db1 - # 为每个数据源单独进行配置 - db0: - # 数据源实现类,这里使用默认的HikariDataSource - type: com.zaxxer.hikari.HikariDataSource - # 数据库驱动 - driver-class-name: com.mysql.cj.jdbc.Driver - # 不用我多说了吧 - jdbc-url: jdbc:mysql://192.168.0.8:3306/yyds - username: root - password: 123456 - db1: - type: com.zaxxer.hikari.HikariDataSource - driver-class-name: com.mysql.cj.jdbc.Driver - jdbc-url: jdbc:mysql://192.168.0.13:3306/yyds - username: root - password: 123456 -``` - -如果启动没有问题,那么就是配置成功了: - -![image-20220414222958901](https://tva1.sinaimg.cn/large/e6c9d24ely1h19mebpqb7j21su0aqgre.jpg) - -接着我们需要对项目进行一些编写,添加我们的用户实体类和Mapper: - -```java -@Data -@AllArgsConstructor -public class User { - int id; - String name; - String passwd; -} -``` - -```java -@Mapper -public interface UserMapper { - - @Select("select * from test where id = #{id}") - User getUserById(int id); - - @Insert("insert into test(id, name, passwd) values(#{id}, #{name}, #{passwd})") - int addUser(User user); -} -``` - -实际上这些操作都是常规操作,在编写代码时关注点依然放在业务本身上,现在我们就来编写配置文件,我们需要告诉ShardingJDBC要如何进行分片,首先明确:现在是两个数据库都有test表存放用户数据,我们目标是将用户信息分别存放到这两个数据库的表中。 - -不废话了,直接上配置: - -```yaml -spring: - shardingsphere: - rules: - sharding: - tables: - #这里填写表名称,程序中对这张表的所有操作,都会采用下面的路由方案 - #比如我们上面Mybatis就是对test表进行操作,所以会走下面的路由方案 - test: - #这里填写实际的路由节点,比如现在我们要分两个库,那么就可以把两个库都写上,以及对应的表 - #也可以使用表达式,比如下面的可以简写为 db$->{0..1}.test - actual-data-nodes: db0.test,db1.test - #这里是分库策略配置 - database-strategy: - #这里选择标准策略,也可以配置复杂策略,基于多个键进行分片 - standard: - #参与分片运算的字段,下面的算法会根据这里提供的字段进行运算 - sharding-column: id - #这里填写我们下面自定义的算法名称 - sharding-algorithm-name: my-alg - sharding-algorithms: - #自定义一个新的算法,名称随意 - my-alg: - #算法类型,官方内置了很多种,这里演示最简单的一种 - type: MOD - props: - sharding-count: 2 - props: - #开启日志,一会方便我们观察 - sql-show: true -``` - -其中,分片算法有很多内置的,可以在这里查询:https://shardingsphere.apache.org/document/5.1.0/cn/user-manual/shardingsphere-jdbc/builtin-algorithm/sharding/,这里我们使用的是MOD,也就是取模分片算法,它会根据主键的值进行取模运算,比如我们这里填写的是2,那么就表示对主键进行模2运算,根据数据源的名称,比如db0就是取模后为0,db1就是取模后为1(官方文档描述的并不是很清楚),也就是说,最终实现的效果就是单数放在`db1`,双数放在`db0`,当然它还支持一些其他的算法,这里就不多介绍了。 - -那么现在我们编写一个测试用例来看看,是否能够按照我们上面的规则进行路由: - -```java -@SpringBootTest -class ShardingJdbcTestApplicationTests { - - @Resource - UserMapper mapper; - - @Test - void contextLoads() { - for (int i = 0; i < 10; i++) { - //这里ID自动生成1-10,然后插入数据库 - mapper.addUser(new User(i, "xxx", "ccc")); - } - } - -} -``` - -现在我们可以开始运行了: - -![image-20220415104401263](https://tva1.sinaimg.cn/large/e6c9d24ely1h1a7m3cjm6j21qe0b6aej.jpg) - -测试通过,我们来看看数据库里面是不是按照我们的规则进行数据插入的: - -![image-20220415104449502](https://tva1.sinaimg.cn/large/e6c9d24ely1h1a7mx6u30j217g0d075w.jpg) - -可以看到这两张表,都成功按照我们指定的路由规则进行插入了,我们来看看详细的路由情况,通过控制台输出的SQL就可以看到: - -![image-20220415105325917](https://tva1.sinaimg.cn/large/e6c9d24ely1h1a7vvcqsij21g20amgpd.jpg) - -可以看到所有的SQL语句都有一个Logic SQL(这个就是我们在Mybatis里面写的,是什么就是什么)紧接着下面就是Actual SQL,也就是说每个逻辑SQL最终会根据我们的策略转换为实际SQL,比如第一条数据,它的id是0,那么实际转换出来的SQL会在db0这个数据源进行插入。 - -这样我们就很轻松地实现了分库策略。 - -分库完成之后,接着我们来看分表,比如现在我们的数据库中有`test_0`和`test_1`两张表,表结构一样,但是我们也是希望能够根据id取模运算的结果分别放到这两个不同的表中,实现思路其实是差不多的,这里首先需要介绍一下两种表概念: - -* **逻辑表:**相同结构的水平拆分数据库(表)的逻辑名称,是 SQL 中表的逻辑标识。 例:订单数据根据主键尾数拆分为 10 张表,分别是 `t_order_0` 到 `t_order_9`,他们的逻辑表名为 `t_order` -* **真实表:**在水平拆分的数据库中真实存在的物理表。 即上个示例中的 `t_order_0` 到 `t_order_9` - -现在我们就以一号数据库为例,那么我们在里面创建上面提到的两张表,之前的那个`test`表删不删都可以,就当做不存在就行了: - -```sql -create table test_0 ( - `id` int primary key, - `name` varchar(255) NULL, - `passwd` varchar(255) NULL -); - -create table test_1 ( - `id` int primary key, - `name` varchar(255) NULL, - `passwd` varchar(255) NULL -); -``` - -![image-20220415110322981](https://tva1.sinaimg.cn/large/e6c9d24ely1h1a8681w2ej211608u3z0.jpg) - -接着我们不要去修改任何的业务代码,Mybatis里面写的是什么依然保持原样,即使我们的表名已经变了,我们需要做的是通过路由来修改原有的SQL,配置如下: - -```yaml -spring: - shardingsphere: - rules: - sharding: - tables: - test: - actual-data-nodes: db0.test_$->{0..1} - #现在我们来配置一下分表策略,注意这里是table-strategy上面是database-strategy - table-strategy: - #基本都跟之前是一样的 - standard: - sharding-column: id - sharding-algorithm-name: my-alg - sharding-algorithms: - my-alg: - #这里我们演示一下INLINE方式,我们可以自行编写表达式来决定 - type: INLINE - props: - #比如我们还是希望进行模2计算得到数据该去的表 - #只需要给一个最终的表名称就行了test_,后面的数字是表达式取模算出的 - #实际上这样写和MOD模式一模一样 - algorithm-expression: test_$->{id % 2} - #没错,查询也会根据分片策略来进行,但是如果我们使用的是范围查询,那么依然会进行全量查询 - #这个我们后面紧接着会讲,这里先写上吧 - allow-range-query-with-inline-sharding: false -``` - -现在我们来测试一下,看看会不会按照我们的策略进行分表插入: - -![image-20220415112809843](https://tva1.sinaimg.cn/large/e6c9d24ely1h1a8w0j0ubj21q80bkgq4.jpg) - -可以看到,根据我们的算法,原本的逻辑表被修改为了最终进行分表计算后的结果,我们来查看一下数据库: - -![image-20220415112908760](https://tva1.sinaimg.cn/large/e6c9d24ely1h1a8x1l1coj21800lqgnj.jpg) - -插入我们了解完毕了,我们来看看查询呢: - -```java -@SpringBootTest -class ShardingJdbcTestApplicationTests { - - @Resource - UserMapper mapper; - - @Test - void contextLoads() { - System.out.println(mapper.getUserById(0)); - System.out.println(mapper.getUserById(1)); - } - -} -``` - -![image-20220415113139917](https://tva1.sinaimg.cn/large/e6c9d24ely1h1a8znk6tuj21j408uacr.jpg) - -可以看到,根据我们配置的策略,查询也会自动选择对应的表进行,是不是感觉有内味了。 - -那么如果是范围查询呢? - -```java -@Select("select * from test where id between #{start} and #{end}") -List getUsersByIdRange(int start, int end); -``` - -```java -@SpringBootTest -class ShardingJdbcTestApplicationTests { - - @Resource - UserMapper mapper; - - @Test - void contextLoads() { - System.out.println(mapper.getUsersByIdRange(3, 5)); - } - -} -``` - -我们来看看执行结果会怎么样: - -![image-20220415113530971](https://tva1.sinaimg.cn/large/e6c9d24ely1h1a93ntucij21qa07wwim.jpg) - -可以看到INLINE算法默认是不支持进行全量查询的,我们得将上面的配置项改成true: - -```yaml -allow-range-query-with-inline-sharding: true -``` - -再次进行测试: - -![image-20220415113652038](https://tva1.sinaimg.cn/large/e6c9d24ely1h1a952ev2ej21rq04wwg8.jpg) - -可以看到,最终出来的SQL语句是直接对两个表都进行查询,然后求出一个并集出来作为最后的结果。 - -当然除了分片之外,还有广播表和绑定表机制,用于多种业务场景下,这里就不多做介绍了,详细请查阅官方文档。 - -### 分布式序列算法 - -前面我们讲解了如何进行分库分表,接着我们来看看分布式序列算法。 - -在复杂分布式系统中,特别是微服构架中,往往需要对大量的数据和消息进行唯一标识。随着系统的复杂,数据的增多,分库分表成为了常见的方案,对数据分库分表后需要有一个唯一ID来标识一条数据或消息(如订单号、交易流水、事件编号等),此时一个能够生成全局唯一ID的系统是非常必要的。 - -比如我们之前创建过学生信息表、图书借阅表、图书管理表,所有的信息都会有一个ID作为主键,并且这个ID有以下要求: - -* 为了区别于其他的数据,这个ID必须是全局唯一的。 -* 主键应该尽可能的保持有序,这样会大大提升索引的查询效率。 - -那么我们在分布式系统下,如何保证ID的生成满足上面的需求呢? - -1. **使用UUID:**UUID是由一组32位数的16进制数字随机构成的,我们可以直接使用JDK为我们提供的UUID类来创建: - - ```java - public static void main(String[] args) { - String uuid = UUID.randomUUID().toString(); - System.out.println(uuid); - } - ``` - - 结果为`73d5219b-dc0f-4282-ac6e-8df17bcd5860`,生成速度非常快,可以看到确实是能够保证唯一性,因为每次都不一样,而且这么长一串那重复的概率真的是小的可怜。 - - 但是它并不满足我们上面的第二个要求,也就是说我们需要尽可能的保证有序,而这里我们得到的都是一些无序的ID。 - -2. **雪花算法(Snowflake):** - - 我们来看雪花算法,它会生成一个一个64bit大小的整型的ID,int肯定是装不下了。 - - ![image-20220415150713707](https://tva1.sinaimg.cn/large/e6c9d24ely1h1af7y9xpyj213609kwf6.jpg) - - 可以看到它主要是三个部分组成,时间+工作机器ID+序列号,时间以毫秒为单位,41个bit位能表示约70年的时间,时间纪元从2016年11月1日零点开始,可以使用到2086年,工作机器ID其实就是节点ID,每个节点的ID都不相同,那么就可以区分出来,10个bit位可以表示最多1024个节点,最后12位就是每个节点下的序列号,因此每台机器每毫秒就可以有4096个系列号。 - - 这样,它就兼具了上面所说的唯一性和有序性了,但是依然是有缺点的,第一个是时间问题,如果机器时间出现倒退,那么就会导致生成重复的ID,并且节点容量只有1024个,如果是超大规模集群,也是存在隐患的。 - -ShardingJDBC支持以上两种算法为我们自动生成ID,文档:https://shardingsphere.apache.org/document/5.1.0/cn/user-manual/shardingsphere-jdbc/builtin-algorithm/keygen/ - -这里,我们就是要ShardingJDBC来让我们的主键ID以雪花算法进行生成,首先是配置数据库,因为我们默认的id是int类型,装不下64位的,改一下: - -```sql -ALTER TABLE `yyds`.`test` MODIFY COLUMN `id` bigint NOT NULL FIRST; -``` - -接着我们需要修改一下Mybatis的插入语句,因为现在id是由ShardingJDBC自动生成,我们就不需要自己加了: - -```java -@Insert("insert into test(name, passwd) values(#{name}, #{passwd})") -int addUser(User user); -``` - -接着我们在配置文件中将我们的算法写上: - -```yaml -spring: - shardingsphere: - datasource: - sharding: - tables: - test: - actual-data-nodes: db0.test,db1.test - #这里还是使用分库策略 - database-strategy: - standard: - sharding-column: id - sharding-algorithm-name: my-alg - #这里使用自定义的主键生成策略 - key-generate-strategy: - column: id - key-generator-name: my-gen - key-generators: - #这里写我们自定义的主键生成算法 - my-gen: - #使用雪花算法 - type: SNOWFLAKE - props: - #工作机器ID,保证唯一就行 - worker-id: 666 - sharding-algorithms: - my-alg: - type: MOD - props: - sharding-count: 2 -``` - -接着我们来编写一下测试用例: - -```java -@SpringBootTest -class ShardingJdbcTestApplicationTests { - - @Resource - UserMapper mapper; - - @Test - void contextLoads() { - for (int i = 0; i < 20; i++) { - mapper.addUser(new User("aaa", "bbb")); - } - } - -} -``` - -可以看到日志: - -![image-20220415154524545](https://tva1.sinaimg.cn/large/e6c9d24ely1h1agbo7cqhj21fi0ein3c.jpg) - -在插入的时候,将我们的SQL语句自行添加了一个id字段,并且使用的是雪花算法生成的值,并且也是根据我们的分库策略在进行插入操作。 - -### 读写分离 - -最后我们来看看读写分离,我们之前实现了MySQL的主从,那么我们就可以将主库作为读,从库作为写: - -![image-20220415155842834](https://tva1.sinaimg.cn/large/e6c9d24ely1h1agpivkf5j21og0ge76c.jpg) - -这里我们还是将数据库变回主从状态,直接删除当前的表,我们重新来过: - -```sql -drop table test; -``` - -我们需要将从库开启只读模式,在MySQL配置中进行修改: - -```properties -read-only = 1 -``` - -这样从库就只能读数据了(但是root账号还是可以写数据),接着我们重启服务器: - -```sh -sudo systemctl restart mysql.service -``` - -然后进入主库,看看状态: - -![image-20220415160249024](https://tva1.sinaimg.cn/large/e6c9d24ely1h1agtsg7urj217k07s0tx.jpg) - -现在我们配置一下从库: - -```sql -change replication source to SOURCE_HOST='192.168.0.13',SOURCE_USER='test',SOURCE_PASSWORD='123456',SOURCE_LOG_FILE='binlog.000007',SOURCE_LOG_POS=19845; -start replica; -``` - -现在我们在主库创建表: - -```sql -create table test ( - `id` bigint primary key, - `name` varchar(255) NULL, - `passwd` varchar(255) NULL -); -``` - -然后我们就可以配置ShardingJDBC了,打开配置文件: - -```yaml -spring: - shardingsphere: - rules: - #配置读写分离 - readwrite-splitting: - data-sources: - #名称随便写 - user-db: - #使用静态类型,动态Dynamic类型可以自动发现auto-aware-data-source-name,这里不演示 - type: Static - props: - #配置写库(只能一个) - write-data-source-name: db0 - #配置从库(多个,逗号隔开) - read-data-source-names: db1 - #负载均衡策略,可以自定义 - load-balancer-name: my-load - load-balancers: - #自定义的负载均衡策略 - my-load: - type: ROUND_ROBIN -``` - -注意把之前改的用户实体类和Mapper改回去,这里我们就不用自动生成ID的了。所有的负载均衡算法地址:https://shardingsphere.apache.org/document/5.1.0/cn/user-manual/shardingsphere-jdbc/builtin-algorithm/load-balance/ - -现在我们就来测试一下吧: - -```java -@SpringBootTest -class ShardingJdbcTestApplicationTests { - - @Resource - UserMapper mapper; - - @Test - void contextLoads() { - mapper.addUser(new User(10, "aaa", "bbb")); - System.out.println(mapper.getUserById(10)); - } - -} -``` - -运行看看SQL日志: - -![image-20220415162741466](https://tva1.sinaimg.cn/large/e6c9d24ely1h1ahjo682tj21je07udik.jpg) - -可以看到,当我们执行插入操作时,会直接向db0进行操作,而读取操作是会根据我们的配置,选择db1进行操作。 - -至此,微服务应用章节到此结束。 diff --git a/青空笔记/SpringCloud笔记/SpringCloud笔记(四).md b/青空笔记/SpringCloud笔记/SpringCloud笔记(四).md deleted file mode 100644 index 3a741c8..0000000 --- a/青空笔记/SpringCloud笔记/SpringCloud笔记(四).md +++ /dev/null @@ -1,1327 +0,0 @@ -![image-20220415163559986](https://tva1.sinaimg.cn/large/e6c9d24ely1h1ahsb4tmsj21sk09iwfi.jpg) - -# 消息队列 - -经过前面的学习,我们已经了解了我们之前的技术在分布式环境下的应用,接着我们来看最后一章的内容。 - -那么,什么是消息队列呢? - -我们之前如果需要进行远程调用,那么一般可以通过发送HTTP请求来完成,而现在,我们可以使用第二种方式,就是消息队列,它能够将发送方发送的信息放入队列中,当新的消息入队时,会通知接收方进行处理,一般消息发送方称为生产者,接收方称为消费者。 - -![image-20220415165805716](https://tva1.sinaimg.cn/large/e6c9d24ely1h1aifat4bgj21qy0g6abi.jpg) - -这样我们所有的请求,都可以直接丢到消息队列中,再由消费者取出,不再是直接连接消费者的形式了,而是加了一个中间商,这也是一种很好的解耦方案,并且在高并发的情况下,由于消费者能力有限,消息队列也能起到一个削峰填谷的作用,堆积一部分的请求,再由消费者来慢慢处理,而不会像直接调用那样请求蜂拥而至。 - -那么,消息队列具体实现有哪些呢: - -* RabbitMQ - 性能很强,吞吐量很高,支持多种协议,集群化,消息的可靠执行特性等优势,很适合企业的开发。 -* Kafka - 提供了超高的吞吐量,ms级别的延迟,极高的可用性以及可靠性,而且分布式可以任意扩展。 -* RocketMQ - 阿里巴巴推出的消息队列,经历过双十一的考验,单机吞吐量高,消息的高可靠性,扩展性强,支持事务等,但是功能不够完整,语言支持性较差。 - -我们这里,主要讲解的是RabbitMQ消息队列。 - -## RabbitMQ 消息队列 - -**官方网站:**https://www.rabbitmq.com - -> RabbitMQ拥有数万计的用户,是最受欢迎的开源消息队列之一,从[T-Mobile](https://www.youtube.com/watch?v=1qcTu2QUtrU)到[Runtastic](https://medium.com/@runtastic/messagebus-handling-dead-letters-in-rabbitmq-using-a-dead-letter-exchange-f070699b952b),RabbitMQ在全球范围内用于小型初创企业和大型企业。 -> -> RabbitMQ轻量级,易于在本地和云端部署,它支持多种消息协议。RabbitMQ可以部署在分布式和联合配置中,以满足大规模、高可用性要求。 -> -> RabbitMQ在许多操作系统和云环境中运行,并为[大多数流行语言](https://www.rabbitmq.com/devtools.html)提供了[广泛的开发者工具](https://www.rabbitmq.com/devtools.html)。 - -我们首先还是来看看如何进行安装。 - -### 安装消息队列 - -**下载地址:**https://www.rabbitmq.com/download.html - -由于除了消息队列本身之外还需要Erlang环境(RabbitMQ就是这个语言开发的)所以我们就在我们的Ubuntu服务器上进行安装。 - -首先是Erlang,比较大,1GB左右: - -```sh -sudo apt install erlang -``` - -接着安装RabbitMQ: - -```sh -sudo apt install rabbitmq-server -``` - -安装完成后,可以输入: - -```sh -sudo rabbitmqctl status -``` - -来查看当前的RabbitMQ运行状态,包括运行环境、内存占用、日志文件等信息: - -``` -Runtime - -OS PID: 13718 -OS: Linux -Uptime (seconds): 65 -Is under maintenance?: false -RabbitMQ version: 3.8.9 -Node name: rabbit@ubuntu-server-2 -Erlang configuration: Erlang/OTP 23 [erts-11.1.8] [source] [64-bit] [smp:2:2] [ds:2:2:10] [async-threads:64] -Erlang processes: 280 used, 1048576 limit -Scheduler run queue: 1 -Cluster heartbeat timeout (net_ticktime): 60 -``` - -这样我们的RabbitMQ服务器就安装完成了,要省事还得是Ubuntu啊。 - -可以看到默认有两个端口名被使用: - -``` -Listeners - -Interface: [::], port: 25672, protocol: clustering, purpose: inter-node and CLI tool communication -Interface: [::], port: 5672, protocol: amqp, purpose: AMQP 0-9-1 and AMQP 1.0 -``` - -我们一会主要使用的就是amqp协议的那个端口`5672`来进行连接,25672是集群化端口,之后我们也会用到。 - -接着我们还可以将RabbitMQ的管理面板开启,这样话就可以在浏览器上进行实时访问和监控了: - -```sh -sudo rabbitmq-plugins enable rabbitmq_management -``` - -再次查看状态,可以看到多了一个管理面板,使用的是HTTP协议: - -``` -Listeners - -Interface: [::], port: 25672, protocol: clustering, purpose: inter-node and CLI tool communication -Interface: [::], port: 5672, protocol: amqp, purpose: AMQP 0-9-1 and AMQP 1.0 -Interface: [::], port: 15672, protocol: http, purpose: HTTP API -``` - -我们打开浏览器直接访问一下: - -![image-20220415203431587](https://tva1.sinaimg.cn/large/e6c9d24ely1h1aooi1nvwj21q20ea75e.jpg) - -可以看到需要我们进行登录才可以进入,我们这里还需要创建一个用户才可以,这里就都用admin: - -```sh -sudo rabbitmqctl add_user 用户名 密码 -``` - -将管理员权限给予我们刚刚创建好的用户: - -```sh -sudo rabbitmqctl set_user_tags admin administrator -``` - -创建完成之后,我们登录一下页面: - -![image-20220415203728664](https://tva1.sinaimg.cn/large/e6c9d24ely1h1aorkpn72j225s0u0wj3.jpg) - -进入了之后会显示当前的消息队列情况,包括版本号、Erlang版本等,这里需要介绍一下RabbitMQ的设计架构,这样我们就知道各个模块管理的是什么内容了: - -![image-20220416103043845](https://tva1.sinaimg.cn/large/e6c9d24ely1h1bcul1hzzj21r40iogq3.jpg) - -* **生产者(Publisher)和消费者(Consumer):**不用多说了吧。 -* **Channel:**我们的客户端连接都会使用一个Channel,再通过Channel去访问到RabbitMQ服务器,注意通信协议不是http,而是amqp协议。 -* **Exchange:**类似于交换机一样的存在,会根据我们的请求,转发给相应的消息队列,每个队列都可以绑定到Exchange上,这样Exchange就可以将数据转发给队列了,可以存在很多个,不同的Exchange类型可以用于实现不同消息的模式。 -* **Queue:**消息队列本体,生产者所有的消息都存放在消息队列中,等待消费者取出。 -* **Virtual Host:**有点类似于环境隔离,不同环境都可以单独配置一个Virtual Host,每个Virtual Host可以包含很多个Exchange和Queue,每个Virtual Host相互之间不影响。 - -### 使用消息队列 - -我们就从最简的的模型开始讲起: - -![image-20220417103647609](https://tva1.sinaimg.cn/large/e6c9d24ely1h1cin640c8j21fg06ajrh.jpg) - -(一个生产者 -> 消息队列 -> 一个消费者) - -生产者只需要将数据丢进消息队列,而消费者只需要将数据从消息队列中取出,这样就实现了生产者和消费者的消息交互。我们现在来演示一下,首先进入到我们的管理页面,这里我们创建一个新的实验环境,只需要新建一个Virtual Host即可: - -![image-20220419143014974](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f0mpm7o2j22cw0kaq5s.jpg) - -添加新的虚拟主机之后,我们可以看到,当前admin用户的主机访问权限中新增了我们刚刚添加的环境: - -![image-20220419143115507](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f0nrhztlj22cu0eqjt5.jpg) - -现在我们来看看交换机: - -![image-20220419143338487](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f0q92geqj21x70u0wix.jpg) - -交换机列表中自动为我们新增了刚刚创建好的虚拟主机相关的预设交换机,一共7个,这里我们首先介绍一下前面两个`direct`类型的交换机,一个是`(AMQP default)`还有一个是`amq.direct`,它们都是直连模式的交换机,我们来看看第一个: - -![image-20220419143612318](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f0sx1l24j22cm0j80v9.jpg) - -第一个交换机是所有虚拟主机都会自带的一个默认交换机,并且此交换机不可删除,此交换机默认绑定到所有的消息队列,如果是通过默认交换机发送消息,那么会根据消息的`routingKey`(之后我们发消息都会指定)决定发送给哪个同名的消息队列,同时也不能显示地将消息队列绑定或解绑到此交换机。 - -我们可以看到,详细信息中,当前交换机特性是持久化的,也就是说就算机器重启,那么此交换机也会保留,如果不是持久化,那么一旦重启就会消失。实际上我们在列表中看到`D`的字样,就表示此交换机是持久化的,包含一会我们要讲解的消息队列列表也是这样,所有自动生成的交换机都是持久化的。 - -我们接着来看第二个交换机,这个交换机是一个普通的直连交换机: - -![image-20220419144200533](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f0yya0xgj22810u0412.jpg) - -这个交换机和我们刚刚介绍的默认交换机类型一致,并且也是持久化的,但是我们可以看到它是具有绑定关系的,如果没有指定的消息队列绑定到此交换机上,那么这个交换机无法正常将信息存放到指定的消息队列中,也是根据`routingKey`寻找消息队列(但是可以自定义) - -我们可以在下面直接操作,让某个队列绑定,这里我们先不进行操作。 - -介绍完了两个最基本的交换机之后(其他类型的交换机我们会在后面进行介绍),我们接着来看消息队列: - -![image-20220419144508881](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f127q11qj22em0deta1.jpg) - -可以看到消息队列列表中没有任何的消息队列,我们可以来尝试添加一个新的消息队列: - -![image-20220419144553817](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f12zs6hxj22cg0i60uv.jpg) - -第一行,我们选择我们刚刚创建好的虚拟主机,在这个虚拟主机下创建此消息队列,接着我们将其类型定义为`Classic`类型,也就是经典类型(其他类型我们会在后面逐步介绍)名称随便起一个,然后持久化我们选择`Transient`暂时的(当然也可以持久化,看你自己)自动删除我们选择`No`(需要至少有一个消费者连接到这个队列,之后,一旦所有与这个队列连接的消费者都断开时,就会自动删除此队列)最下面的参数我们暂时不进行任何设置(之后会用到) - -现在,我们就创建好了一个经典的消息队列: - -![image-20220419145109450](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f18gv4xoj22d80dy40j.jpg) - -点击此队列的名称,我们可以查看详细信息: - -![image-20220419145238458](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f1a07outj22cu0o6tc5.jpg) - -详细相信中包括队列的当前负载状态、属性、消息队列占用的内存,消息数量等,一会我们发送消息时可以进一步进行观察。 - -现在我们需要将此消息队列绑定到上面的第二个直连交换机,这样我们就可以通过此交换机向此消息队列发送消息了: - -![image-20220419145520844](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f1ctvnhmj22da0im75w.jpg) - -这里填写之前第二个交换机的名称还有我们自定义的`routingKey`(最好还是和消息队列名称一致,这里是为了一会演示两个交换机区别用)我们直接点击绑定即可: - -![image-20220419145635179](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f1e42r9cj21rg0bumxv.jpg) - -绑定之后我们可以看到当前队列已经绑定对应的交换机了,现在我们可以前往交换机对此消息队列发送一个消息: - -![image-20220419145725499](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f1ez7m11j22da0os0v4.jpg) - -回到交换机之后,可以卡到这边也是同步了当前的绑定信息,在下方,我们直接向此消息队列发送信息: - -![image-20220419145808450](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f1fq3w0zj22e40l6abl.jpg) - -点击发送之后,我们回到刚刚的交换机详细页面,可以看到已经有一条新的消息在队列中了: - -![image-20220419145903723](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f1gojajxj22cw0lo0vh.jpg) - -我们可以直接在消息队列这边获取消息队列中的消息,找到下方的Get message选项: - -![image-20220419145936160](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f1h8qcnhj21q80ccmy4.jpg) - -可以看到有三个选择,首先第一个Ack Mode,这个是应答模式选择,一共有4个选项: - -![image-20220419150053926](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f1im04y6j214g05a3yv.jpg) - -* Nack message requeue true:拒绝消息,也就是说不会将消息从消息队列取出,并且重新排队,一次可以拒绝多个消息。 -* Ack message requeue false:确认应答,确认后消息会从消息队列中移除,一次可以确认多个消息。 -* Reject message requeue true/false:也是拒绝此消息,但是可以指定是否重新排队。 - -这里我们使用默认的就可以了,这样只会查看消息是啥,但是不会取出,消息依然存在于消息队列中,第二个参数是编码格式,使用默认的就可以了,最后就是要生效的操作数量,选择1就行: - -![image-20220419150712314](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f1p61hjcj21l20di758.jpg) - -可以看到我们刚刚的消息已经成功读取到。 - -现在我们再去第一个默认交换机中尝试发送消息试试看: - -![image-20220419150913859](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f1r9rzvbj21ha07m74n.jpg) - -如果我们使用之前自定义的`routingKey`,会显示没有路由,这是因为默认的交换机只会找对应名称的消息队列,我们现在向`yyds`发送一下试试看: - -![image-20220419151016735](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f1sdaa6bj21dk064wek.jpg) - -可以看到消息成功发布了,我们来接收一下看看: - -![image-20220419151058659](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f1t2qdtmj221c0lymz8.jpg) - -可以看到成功发送到此消息队列中了。 - -当然除了在交换机发送消息给消息队列之外,我们也可以直接在消息队列这里发: - -![image-20220419151155264](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f1u2nhz7j21xc0lwq4g.jpg) - -效果是一样的,注意这里我们可以选择是否将消息持久化,如果是持久化消息,那么就算服务器重启,此消息也会保存在消息队列中。 - -最后如果我们不需要再使用此消息队列了,我们可以手动对其进行删除或是清空: - -![image-20220419151548923](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f1y44f9dj21lu096t90.jpg) - -点击Delete Queue删除我们刚刚创建好的`yyds`队列,到这里,我们对应消息队列的一些简单使用,就讲解完毕了。 - -### 使用Java操作消息队列 - -现在我们来看看如何通过Java连接到RabbitMQ服务器并使用消息队列进行消息发送(这里一起讲解,包括Java基础版本和SpringBoot版本),首先我们使用最基本的Java客户端连接方式: - -```xml - - com.rabbitmq - amqp-client - 5.14.2 - -``` - -依赖导入之后,我们来实现一下生产者和消费者,首先是生产者,生产者负责将信息发送到消息队列: - -```java -public static void main(String[] args) { - //使用ConnectionFactory来创建连接 - ConnectionFactory factory = new ConnectionFactory(); - - //设定连接信息,基操 - factory.setHost("192.168.0.12"); - factory.setPort(5672); //注意这里写5672,是amqp协议端口 - factory.setUsername("admin"); - factory.setPassword("admin"); - factory.setVirtualHost("/test"); - - //创建连接 - try(Connection connection = factory.newConnection()){ - - }catch (Exception e){ - e.printStackTrace(); - } -} -``` - -这里我们可以直接在程序中定义并创建消息队列(实际上是和我们在管理页面创建一样的效果)客户端需要通过连接创建一个新的通道(Channel),同一个连接下可以有很多个通道,这样就不用创建很多个连接也能支持分开发送了。 - -```java -try(Connection connection = factory.newConnection(); - Channel channel = connection.createChannel()){ //通过Connection创建新的Channel - //声明队列,如果此队列不存在,会自动创建 - channel.queueDeclare("yyds", false, false, false, null); - //将队列绑定到交换机 - channel.queueBind("yyds", "amq.direct", "my-yyds"); - //发布新的消息,注意消息需要转换为byte[] - channel.basicPublish("amq.direct", "my-yyds", null, "Hello World!".getBytes()); -}catch (Exception e){ - e.printStackTrace(); -} -``` - -其中`queueDeclare`方法的参数如下: - -* queue:队列的名称(默认创建后routingKey和队列名称一致) -* durable:是否持久化。 -* exclusive:是否排他,如果一个队列被声明为排他队列,该队列仅对首次声明它的连接可见,并在连接断开时自动删除。排他队列是基于Connection可见,同一个Connection的不同Channel是可以同时访问同一个连接创建的排他队列,并且,如果一个Connection已经声明了一个排他队列,其他的Connection是不允许建立同名的排他队列的,即使该队列是持久化的,一旦Connection关闭或者客户端退出,该排他队列都会自动被删除。 -* autoDelete:是否自动删除。 -* arguments:设置队列的其他一些参数,这里我们暂时不需要什么其他参数。 - -其中`queueBind`方法参数如下: - -* queue:需要绑定的队列名称。 -* exchange:需要绑定的交换机名称。 -* routingKey:不用多说了吧。 - -其中`basicPublish`方法的参数如下: - -* exchange: 对应的Exchange名称,我们这里就使用第二个直连交换机。 -* routingKey:这里我们填写绑定时指定的routingKey,其实和之前在管理页面操作一样。 -* props:其他的配置。 -* body:消息本体。 - -执行完成后,可以在管理页面中看到我们刚刚创建好的消息队列了: - -![image-20220419153630431](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f2jnj0jzj22dc0de0ui.jpg) - -并且此消息队列已经成功与`amq.direct`交换机进行绑定: - -![image-20220419154618613](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f2tup1pmj21pa0bowf9.jpg) - -那么现在我们的消息队列中已经存在数据了,怎么将其读取出来呢?我们来看看如何创建一个消费者: - -```java -public static void main(String[] args) throws IOException, TimeoutException { - ConnectionFactory factory = new ConnectionFactory(); - factory.setHost("10.37.129.4"); - factory.setPort(5672); - factory.setUsername("admin"); - factory.setPassword("admin"); - factory.setVirtualHost("/test"); - - //这里不使用try-with-resource,因为消费者是一直等待新的消息到来,然后按照 - //我们设定的逻辑进行处理,所以这里不能在定义完成之后就关闭连接 - Connection connection = factory.newConnection(); - Channel channel = connection.createChannel(); - - //创建一个基本的消费者 - channel.basicConsume("yyds", false, (s, delivery) -> { - System.out.println(new String(delivery.getBody())); - //basicAck是确认应答,第一个参数是当前的消息标签,后面的参数是 - //是否批量处理消息队列中所有的消息,如果为false表示只处理当前消息 - channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false); - //basicNack是拒绝应答,最后一个参数表示是否将当前消息放回队列,如果 - //为false,那么消息就会被丢弃 - //channel.basicNack(delivery.getEnvelope().getDeliveryTag(), false, false); - //跟上面一样,最后一个参数为false,只不过这里省了 - //channel.basicReject(delivery.getEnvelope().getDeliveryTag(), false); - }, s -> {}); -} -``` - -其中`basicConsume`方法参数如下: - -* queue - 消息队列名称,直接指定。 -* autoAck - 自动应答,消费者从消息队列取出数据后,需要跟服务器进行确认应答,当服务器收到确认后,会自动将消息删除,如果开启自动应答,那么消息发出后会直接删除。 -* deliver - 消息接收后的函数回调,我们可以在回调中对消息进行处理,处理完成后,需要给服务器确认应答。 -* cancel - 当消费者取消订阅时进行的函数回调,这里暂时用不到。 - -现在我们启动一下消费者,可以看到立即读取到我们刚刚插入到队列中的数据: - -![image-20220419155938158](https://tva1.sinaimg.cn/large/e6c9d24egy1h1f37pxs0pj21h207q40k.jpg) - -我们现在继续在消息队列中插入新的数据,这里直接在网页上进行操作就行了,同样的我们也可以在消费者端接受并进行处理。 - -现在我们把刚刚创建好的消息队列删除。 - -官方文档:https://docs.spring.io/spring-amqp/docs/current/reference/html/ - -前面我们已经完成了RabbitMQ的安装和简单使用,并且通过Java连接到服务器。现在我们来尝试在SpringBoot中整合消息队列客户端,首先是依赖: - -```xml - - org.springframework.boot - spring-boot-starter-amqp - -``` - -接着我们需要配置RabbitMQ的地址等信息: - -```yaml -spring: - rabbitmq: - addresses: 192.168.0.4 - username: admin - password: admin - virtual-host: /test -``` - -这样我们就完成了最基本信息配置,现在我们来看一下,如何像之前一样去声明一个消息队列,我们只需要一个配置类就行了: - -```java -@Configuration -public class RabbitConfiguration { - @Bean("directExchange") //定义交换机Bean,可以很多个 - public Exchange exchange(){ - return ExchangeBuilder.directExchange("amq.direct").build(); - } - - @Bean("yydsQueue") //定义消息队列 - public Queue queue(){ - return QueueBuilder - .nonDurable("yyds") //非持久化类型 - .build(); - } - - @Bean("binding") - public Binding binding(@Qualifier("directExchange") Exchange exchange, - @Qualifier("yydsQueue") Queue queue){ - //将我们刚刚定义的交换机和队列进行绑定 - return BindingBuilder - .bind(queue) //绑定队列 - .to(exchange) //到交换机 - .with("my-yyds") //使用自定义的routingKey - .noargs(); - } -} -``` - -接着我们来创建一个生产者,这里我们直接编写在测试用例中: - -```java -@SpringBootTest -class SpringCloudMqApplicationTests { - - //RabbitTemplate为我们封装了大量的RabbitMQ操作,已经由Starter提供,因此直接注入使用即可 - @Resource - RabbitTemplate template; - - @Test - void publisher() { - //使用convertAndSend方法一步到位,参数基本和之前是一样的 - //最后一个消息本体可以是Object类型,真是大大的方便 - template.convertAndSend("amq.direct", "my-yyds", "Hello World!"); - } - -} -``` - -现在我们来运行一下这个测试用例: - -![image-20220419221426545](https://tva1.sinaimg.cn/large/e6c9d24ely1h1fe1qms43j219c03kgme.jpg) - -可以看到后台自动声明了我们刚刚定义好的消息队列和交换机以及对应的绑定关系,并且我们的数据也是成功插入到消息队列中: - -![image-20220419221532673](https://tva1.sinaimg.cn/large/e6c9d24ely1h1fe2uj02vj21wu0fkmyf.jpg) - -现在我们再来看看如何创建一个消费者,因为消费者实际上就是一直等待消息然后进行处理的角色,这里我们只需要创建一个监听器就行了,它会一直等待消息到来然后再进行处理: - -```java -@Component //注册为Bean -public class TestListener { - - @RabbitListener(queues = "yyds") //定义此方法为队列yyds的监听器,一旦监听到新的消息,就会接受并处理 - public void test(Message message){ - System.out.println(new String(message.getBody())); - } -} -``` - -接着我们启动服务器: - -![image-20220419230223151](https://tva1.sinaimg.cn/large/e6c9d24ely1h1fffku7l0j228w0faaip.jpg) - -可以看到控制台成功输出了我们之前放入队列的消息,并且管理页面中也显示此消费者已经连接了: - -![image-20220419230315376](https://tva1.sinaimg.cn/large/e6c9d24ely1h1ffghhmdyj21wg05ogmh.jpg) - -接着我们再通过管理页面添加新的消息看看,也是可以正常进行接受的。 - -当然,如果我们需要确保消息能够被消费者接受并处理,然后得到消费者的反馈,也是可以的: - -```java -@Test -void publisher() { - //会等待消费者消费然后返回响应结果 - Object res = template.convertSendAndReceive("amq.direct", "my-yyds", "Hello World!"); - System.out.println("收到消费者响应:"+res); -} -``` - -消费者这边只需要返回一个对应的结果即可: - -```java -@RabbitListener(queues = "yyds") -public String receiver(String data){ - System.out.println("一号消息队列监听器 "+data); - return "收到!"; -} -``` - -测试没有问题: - -![image-20220421142425891](https://tva1.sinaimg.cn/large/e6c9d24ely1h1hbp9t5wsj21ce066gmt.jpg) - -那么如果我们需要直接接收一个JSON格式的消息,并且希望直接获取到实体类呢? - -```java -@Data -public class User { - int id; - String name; -} -``` - -```java -@Configuration -public class RabbitConfiguration { - ... - - @Bean("jacksonConverter") //直接创建一个用于JSON转换的Bean - public Jackson2JsonMessageConverter converter(){ - return new Jackson2JsonMessageConverter(); - } -} -``` - -接着我们只需要指定转换器就可以了: - -```java -@Component -public class TestListener { - - //指定messageConverter为我们刚刚创建的Bean名称 - @RabbitListener(queues = "yyds", messageConverter = "jacksonConverter") - public void receiver(User user){ //直接接收User类型 - System.out.println(user); - } -} -``` - -现在我们直接在管理页面发送: - -```json -{"id":1,"name":"LB"} -``` - -![image-20220416225912100](https://tva1.sinaimg.cn/large/e6c9d24ely1h1byhcakabj221m0lwac0.jpg) - -可以看到成功完成了转换,并输出了用户信息: - -![image-20220416225829807](https://tva1.sinaimg.cn/large/e6c9d24ely1h1byglk5pmj21ji04imyp.jpg) - -同样的,我们也可以直接发送User,因为我们刚刚已经配置了Jackson2JsonMessageConverter为Bean,所以直接使用就可以了: - -```java -@Test -void publisher() { - template.convertAndSend("amq.direct", "yyds", new User()); -} -``` - -可以看到后台的数据类型为: - -![image-20220419232715025](https://tva1.sinaimg.cn/large/e6c9d24ely1h1fg5g2h6xj21k20gmmyl.jpg) - -![image-20220416231709750](https://tva1.sinaimg.cn/large/e6c9d24ely1h1bz013oa7j21c804q755.jpg) - -这样,我们就通过SpringBoot实现了RabbitMQ的简单使用。 - -### 死信队列 - -消息队列中的数据,如果迟迟没有消费者来处理,那么就会一直占用消息队列的空间。比如我们模拟一下抢车票的场景,用户下单高铁票之后,会进行抢座,然后再进行付款,但是如果用户下单之后并没有及时的付款,这张票不可能一直让这个用户占用着,因为你不买别人还要买呢,所以会在一段时间后超时,让这张票可以继续被其他人购买。 - -这时,我们就可以使用死信队列,将那些用户超时未付款的或是用户主动取消的订单,进行进一步的处理,以下类型的消息都会被判定为死信: - -- 消息被拒绝(basic.reject / basic.nack),并且requeue = false -- 消息TTL过期 -- 队列达到最大长度 - -![image-20220419112336088](https://tva1.sinaimg.cn/large/e6c9d24egy1h1ev8hxtm7j21do07g0tn.jpg) - -那么如何构建这样的一种使用模式呢?实际上本质就是一个死信交换机+绑定的死信队列,当正常队列中的消息被判定为死信时,会被发送到对应的死信交换机,然后再通过交换机发送到死信队列中,死信队列也有对应的消费者去处理消息。 - -这里我们直接在配置类中创建一个新的死信交换机和死信队列,并进行绑定: - -```java -@Configuration -public class RabbitConfiguration { - - @Bean("directDlExchange") - public Exchange dlExchange(){ - //创建一个新的死信交换机 - return ExchangeBuilder.directExchange("dlx.direct").build(); - } - - @Bean("yydsDlQueue") //创建一个新的死信队列 - public Queue dlQueue(){ - return QueueBuilder - .nonDurable("dl-yyds") - .build(); - } - - @Bean("dlBinding") //死信交换机和死信队列进绑定 - public Binding dlBinding(@Qualifier("directDlExchange") Exchange exchange, - @Qualifier("yydsDlQueue") Queue queue){ - return BindingBuilder - .bind(queue) - .to(exchange) - .with("dl-yyds") - .noargs(); - } - - ... - - @Bean("yydsQueue") - public Queue queue(){ - return QueueBuilder - .nonDurable("yyds") - .deadLetterExchange("dlx.direct") //指定死信交换机 - .deadLetterRoutingKey("dl-yyds") //指定死信RoutingKey - .build(); - } - - ... -} -``` - -接着我们将监听器修改为死信队列监听: - -```java -@Component -public class TestListener { - @RabbitListener(queues = "dl-yyds", messageConverter = "jacksonConverter") - public void receiver(User user){ - System.out.println(user); - } -} -``` - -配置完成后,我们来尝试启动一下吧,注意启动之前记得把之前的队列给删了,这里要重新定义。 - -![image-20220420103846981](https://tva1.sinaimg.cn/large/e6c9d24ely1h1fzk61bo9j21pu06wdgy.jpg) - -队列列表中已经出现了我们刚刚定义好的死信队列,并且yyds队列也支持死信队列发送功能了,现在我们尝试向此队列发送一个消息,但是我们将其拒绝: - -![image-20220420105359931](https://tva1.sinaimg.cn/large/e6c9d24ely1h1g000aphbj21ri08yaau.jpg) - -可以看到拒绝后,如果不让消息重新排队,那么就会变成死信,直接被丢进死信队列中,可以看到在拒绝后: - -![image-20220420105455291](https://tva1.sinaimg.cn/large/e6c9d24ely1h1g00ywpelj21lk03q75w.jpg) - -现在我们来看看第二种情况,RabbitMQ支持将超过一定时间没被消费的消息自动删除,这需要消息队列设定TTL值,如果消息的存活时间超过了Time To Live值,就会被自动删除,自动删除后的消息如果有死信队列,那么就会进入到死信队列中。 - -现在我们将yyds消息队列设定TTL值(毫秒为单位): - -```java -@Bean("yydsQueue") -public Queue queue(){ - return QueueBuilder - .nonDurable("yyds") - .deadLetterExchange("dlx.direct") - .deadLetterRoutingKey("dl-yyds") - .ttl(5000) //如果5秒没处理,就自动删除 - .build(); -} -``` - -现在我们重启测试一下,注意修改了之后记得删除之前的yyds队列: - -![image-20220420110317997](https://tva1.sinaimg.cn/large/e6c9d24ely1h1g09oljcsj21lw06agmx.jpg) - -可以看到现在yyds队列已经具有TTL特性了,我们现在来插入一个新的消息: - -![image-20220420110504022](https://tva1.sinaimg.cn/large/e6c9d24ely1h1g0bin3whj21lk08mjs4.jpg) - -可以看到消息5秒钟之后就不见了,而是被丢进了死信队列中。 - -最后我们来看一下当消息队列长度达到最大的情况,现在我们将消息队列的长度进行限制: - -```java -@Bean("yydsQueue") -public Queue queue(){ - return QueueBuilder - .nonDurable("yyds") - .deadLetterExchange("dlx.direct") - .deadLetterRoutingKey("dl-yyds") - .maxLength(3) //将最大长度设定为3 - .build(); -} -``` - -现在我们重启一下,然后尝试连续插入4个消息: - -![image-20220420135316458](https://tva1.sinaimg.cn/large/e6c9d24egy1h1g56k0whnj21l008awfp.jpg) - -可以看到yyds消息队列新增了Limit特性,也就是限定长度: - -```java -@Test -void publisher() { - for (int i = 0; i < 4; i++) - template.convertAndSend("amq.direct", "my-yyds", new User()); -} -``` - -![image-20220420135419673](https://tva1.sinaimg.cn/large/e6c9d24egy1h1g57n6wlpj21r4032wfx.jpg) - -可以看到因为长度限制为3,所以有一个消息直接被丢进了死信队列中,为了能够更直观地观察消息队列的机制,我们为User类新增一个时间字段: - -```java -@Data -public class User { - int id; - String name; - String date = new Date().toString(); -} -``` - -接着每隔一秒钟插入一个: - -```java -@Test -void publisher() throws InterruptedException { - for (int i = 0; i < 4; i++) { - Thread.sleep(1000); - template.convertAndSend("amq.direct", "my-yyds", new User()); - } -} -``` - -再次进行上述实验,可以发现如果到达队列长度限制,那么每次插入都会把位于队首的消息丢进死信队列,来腾出空间给新来的消息。 - -### 工作队列模式 - -**注意:**XX模式只是一种设计思路,并不是指的具体的某种实现,可以理解为实现XX模式需要怎么去写。 - -前面我们了解了最简的一个消费者一个生产者的模式,接着我们来了解一下一个生产者多个消费者的情况: - -![image-20220420151258324](https://tva1.sinaimg.cn/large/e6c9d24egy1h1g7hh8h18j21he06mt8x.jpg) - -实际上这种模式就非常适合多个工人等待新的任务到来的场景,我们的任务有很多个,一个一个丢进消息队列,而此时工人有很多个,那么我们就可以将这些任务分配个各个工人,让他们各自负责一些任务,并且做的快的工人还可以做完成一些(能者多劳)。 - -非常简单,我们只需要创建两个监听器即可: - -```java -@Component -public class TestListener { - @RabbitListener(queues = "yyds") - public void receiver(String data){ //这里直接接收String类型的数据 - System.out.println("一号消息队列监听器 "+data); - } - - @RabbitListener(queues = "yyds") - public void receiver2(String data){ - System.out.println("二号消息队列监听器 "+data); - } -} -``` - -可以看到我们发送消息时,会自动进行轮询分发: - -![image-20220420154602883](https://tva1.sinaimg.cn/large/e6c9d24egy1h1g8fw63klj21f406gzl5.jpg) - -那么如果我们一开始就在消息队列中放入一部分消息在开启消费者呢? - -![image-20220420154654901](https://tva1.sinaimg.cn/large/e6c9d24egy1h1g8gs2duqj21fe05s74x.jpg) - -可以看到,如果是一开始就存在消息,会被一个消费者一次性全部消耗,这是因为我们没有对消费者的Prefetch count(预获取数量,一次性获取消息的最大数量)进行限制,也就是说我们现在希望的是消费者一次只能拿一个消息,而不是将所有的消息全部都获取。 - -![image-20220420160253144](https://tva1.sinaimg.cn/large/e6c9d24egy1h1g8xeq182j21fo05qt9x.jpg) - -因此我们需要对这个数量进行一些配置,这里我们需要在配置类中定义一个自定义的ListenerContainerFactory,可以在这里设定消费者Channel的PrefetchCount的大小: - -```java -@Resource -private CachingConnectionFactory connectionFactory; - -@Bean(name = "listenerContainer") -public SimpleRabbitListenerContainerFactory listenerContainer(){ - SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory(); - factory.setConnectionFactory(connectionFactory); - factory.setPrefetchCount(1); //将PrefetchCount设定为1表示一次只能取一个 - return factory; -} -``` - -接着我们在监听器这边指定即可: - -```java -@Component -public class TestListener { - @RabbitListener(queues = "yyds", containerFactory = "listenerContainer") - public void receiver(String data){ - System.out.println("一号消息队列监听器 "+data); - } - - @RabbitListener(queues = "yyds", containerFactory = "listenerContainer") - public void receiver2(String data){ - System.out.println("二号消息队列监听器 "+data); - } -} -``` - -现在我们再次启动服务器,可以看到PrefetchCount被限定为1了: - -![image-20220420164702864](https://tva1.sinaimg.cn/large/e6c9d24egy1h1ga7d3x42j21m006sdh7.jpg) - -再次重复上述的实现,可以看到消息不会被一号消费者给全部抢走了: - -![image-20220420164827502](https://tva1.sinaimg.cn/large/e6c9d24egy1h1ga8tuajtj21la05sgmd.jpg) - -当然除了去定义两个相同的监听器之外,我们也可以直接在注解中定义,比如我们现在需要10个同样的消费者: - -```java -@Component -public class TestListener { - @RabbitListener(queues = "yyds", containerFactory = "listenerContainer", concurrency = "10") - public void receiver(String data){ - System.out.println("一号消息队列监听器 "+data); - } -} -``` - -可以看到在管理页面中出现了10个消费者: - -![image-20220420170349298](https://tva1.sinaimg.cn/large/e6c9d24egy1h1gaotg34ij21qg0fuaeh.jpg) - -至此,有关工作队列模式就讲到这里。 - -### 发布订阅模式 - -前面我们已经了解了RabbitMQ客户端的一些基本操作,包括普通的消息模式,接着我们来了解一下其他的模式,首先是发布订阅模式,它支持多种方式: - -![image-20220420172252440](https://tva1.sinaimg.cn/large/e6c9d24egy1h1gb8mwfc9j21gy08emxj.jpg) - -比如我们在阿里云买了云服务器,但是最近快到期了,那么就会给你的手机、邮箱发送消息,告诉你需要去续费了,但是手机短信和邮件发送并不一定是同一个业务提供的,但是现在我们又希望能够都去执行,所以就可以用到发布订阅模式,简而言之就是,发布一次,消费多个。 - -实现这种模式其实也非常简单,但是如果使用我们之前的直连交换机,肯定是不行的,我们这里需要用到另一种类型的交换机,叫做`fanout`(扇出)类型,这时一种广播类型,消息会被广播到所有与此交换机绑定的消息队列中。 - -这里我们使用默认的交换机: - -![image-20220420225300171](https://tva1.sinaimg.cn/large/e6c9d24ely1h1gks5vbsxj20za020t8p.jpg) - -这个交换机是一个`fanout`类型的交换机,我们就是要它就行了: - -```java -@Configuration -public class RabbitConfiguration { - - @Bean("fanoutExchange") - public Exchange exchange(){ - //注意这里是fanoutExchange - return ExchangeBuilder.fanoutExchange("amq.fanout").build(); - } - - @Bean("yydsQueue1") - public Queue queue(){ - return QueueBuilder.nonDurable("yyds1").build(); - } - - @Bean("binding") - public Binding binding(@Qualifier("fanoutExchange") Exchange exchange, - @Qualifier("yydsQueue1") Queue queue){ - return BindingBuilder - .bind(queue) - .to(exchange) - .with("yyds1") - .noargs(); - } - - @Bean("yydsQueue2") - public Queue queue2(){ - return QueueBuilder.nonDurable("yyds2").build(); - } - - @Bean("binding2") - public Binding binding2(@Qualifier("fanoutExchange") Exchange exchange, - @Qualifier("yydsQueue2") Queue queue){ - return BindingBuilder - .bind(queue) - .to(exchange) - .with("yyds2") - .noargs(); - } -} -``` - -这里我们将两个队列都绑定到此交换机上,我们先启动看看效果: - -![image-20220420230954785](https://tva1.sinaimg.cn/large/e6c9d24ely1h1gl9px6doj21tc0bimxv.jpg) - -绑定没有什么问题,接着我们搞两个监听器,监听一下这两个队列: - -```java -@Component -public class TestListener { - @RabbitListener(queues = "yyds1") - public void receiver(String data){ - System.out.println("一号消息队列监听器 "+data); - } - - @RabbitListener(queues = "yyds2") - public void receiver2(String data){ - System.out.println("二号消息队列监听器 "+data); - } -} -``` - -现在我们通过交换机发送消息,看看是不是两个监听器都会接收到消息: - -![image-20220420231113658](https://tva1.sinaimg.cn/large/e6c9d24ely1h1glb33v3wj21ky0k80ts.jpg) - -可以看到确实是两个消息队列都能够接受到此消息: - -![image-20220420231145578](https://tva1.sinaimg.cn/large/e6c9d24ely1h1glbn729uj21be03umxp.jpg) - -这样我们就实现了发布订阅模式。 - -### 路由模式 - -路由模式实际上我们一开始就已经实现了,我们可以在绑定时指定想要的`routingKey`只有生产者发送时指定了对应的`routingKey`才能到达对应的队列。 - -![image-20220420232826848](https://tva1.sinaimg.cn/large/e6c9d24ely1h1glt0bescj21dk08mjru.jpg) - -当然除了我们之前的一次绑定之外,同一个消息队列可以多次绑定到交换机,并且使用不同的`routingKey`,这样只要满足其中一个都可以被发送到此消息队列中: - -```java -@Configuration -public class RabbitConfiguration { - - @Bean("directExchange") - public Exchange exchange(){ - return ExchangeBuilder.directExchange("amq.direct").build(); - } - - @Bean("yydsQueue") - public Queue queue(){ - return QueueBuilder.nonDurable("yyds").build(); - } - - @Bean("binding") //使用yyds1绑定 - public Binding binding(@Qualifier("directExchange") Exchange exchange, - @Qualifier("yydsQueue") Queue queue){ - return BindingBuilder - .bind(queue) - .to(exchange) - .with("yyds1") - .noargs(); - } - - @Bean("binding2") //使用yyds2绑定 - public Binding binding2(@Qualifier("directExchange") Exchange exchange, - @Qualifier("yydsQueue") Queue queue){ - return BindingBuilder - .bind(queue) - .to(exchange) - .with("yyds2") - .noargs(); - } -} -``` - -启动后我们可以看到管理面板中出现了两个绑定关系: - -![image-20220420233606749](https://tva1.sinaimg.cn/large/e6c9d24ely1h1gm0zb3hej21kq0eagmk.jpg) - -这里可以测试一下,随便使用哪个`routingKey`都可以。 - -### 主题模式 - -实际上这种模式就是一种模糊匹配的模式,我们可以将`routingKey`以模糊匹配的方式去进行转发。 - -![image-20220420233721239](https://tva1.sinaimg.cn/large/e6c9d24ely1h1gm29nni5j21fs08874r.jpg) - -我们可以使用`*`或`#`来表示: - -- \* - 表示任意的一个单词 -- \# - 表示0个或多个单词 - -这里我们来测试一下: - -```java -@Configuration -public class RabbitConfiguration { - - @Bean("topicExchange") //这里使用预置的Topic类型交换机 - public Exchange exchange(){ - return ExchangeBuilder.topicExchange("amq.topic").build(); - } - - @Bean("yydsQueue") - public Queue queue(){ - return QueueBuilder.nonDurable("yyds").build(); - } - - @Bean("binding") - public Binding binding2(@Qualifier("topicExchange") Exchange exchange, - @Qualifier("yydsQueue") Queue queue){ - return BindingBuilder - .bind(queue) - .to(exchange) - .with("*.test.*") - .noargs(); - } -} -``` - -启动项目,可以看到只要是满足通配符条件的都可以成功转发到对应的消息队列: - -![image-20220421103753962](https://tva1.sinaimg.cn/large/e6c9d24ely1h1h55kezhaj21jk0ke0to.jpg) - -接着我们可以再试试看`#`通配符。 - -除了我们这里使用的默认主题交换机之外,还有一个叫做`amq.rabbitmq.trace`的交换机: - -![image-20220421104035463](https://tva1.sinaimg.cn/large/e6c9d24ely1h1h58cwa5oj20z6024jrf.jpg) - -可以看到它也是`topic`类型的,那么这个交换机是做什么的呢?实际上这是用于帮助我们记录和追踪生产者和消费者使用消息队列的交换机,它是一个内部的交换机,那么如果使用呢?首先创建一个消息队列用于接收记录: - -![image-20220421104619325](https://tva1.sinaimg.cn/large/e6c9d24ely1h1h5ebv4vnj21f407wt9u.jpg) - -接着我们需要在控制台将虚拟主机`/test`的追踪功能开启: - -```sh -sudo rabbitmqctl trace_on -p /test -``` - -开启后,我们将此队列绑定到上面的交换机上: - -![image-20220421104843224](https://tva1.sinaimg.cn/large/e6c9d24ely1h1h5gtqdrlj21gk09st9f.jpg) - -![image-20220421105141144](https://tva1.sinaimg.cn/large/e6c9d24ely1h1h5jwqqf1j21cc09kdgg.jpg) - -由于发送到此交换机上的`routingKey`为routing key为 publish.交换机名称 和 deliver.队列名称,分别对应生产者投递到交换机的消息,和消费者从队列上获取的消息,因此这里使用`#`通配符进行绑定。 - -现在我们来测试一下,比如还是往yyds队列发送消息: - -![image-20220421105242770](https://tva1.sinaimg.cn/large/e6c9d24ely1h1h5kz432vj21ks080mxw.jpg) - -可以看到在发送消息,并且消费者已经处理之后,`trace`队列中新增了两条消息,那么我们来看看都是些什么消息: - -![image-20220421105528532](https://tva1.sinaimg.cn/large/e6c9d24ely1h1h5nunf9kj21jg0m0jth.jpg) - -通过追踪,我们可以很明确地得知消息发送的交换机、routingKey、用户等信息,包括信息本身,同样的,消费者在取出数据时也有记录: - -![image-20220421105638715](https://tva1.sinaimg.cn/large/e6c9d24ely1h1h5p26b1oj21nc0k440e.jpg) - -我们可以明确消费者的地址、端口、具体操作的队列以及取出的消息信息等。 - -到这里,我们就已经了解了3种类型的交换机。 - -### 第四种交换机类型 - -通过前面的学习,我们已经介绍了三种交换机类型,现在我们来介绍一下第四种交换机类型`header`,它是根据头部信息来决定的,在我们发送的消息中是可以携带一些头部信息的(类似于HTTP),我们可以根据这些头部信息来决定路由到哪一个消息队列中。 - -```java -@Configuration -public class RabbitConfiguration { - - @Bean("headerExchange") //注意这里返回的是HeadersExchange - public HeadersExchange exchange(){ - return ExchangeBuilder - .headersExchange("amq.headers") //RabbitMQ为我们预置了两个,这里用第一个就行 - .build(); - } - - @Bean("yydsQueue") - public Queue queue(){ - return QueueBuilder.nonDurable("yyds").build(); - } - - @Bean("binding") - public Binding binding2(@Qualifier("headerExchange") HeadersExchange exchange, //这里和上面一样的类型 - @Qualifier("yydsQueue") Queue queue){ - return BindingBuilder - .bind(queue) - .to(exchange) //使用HeadersExchange的to方法,可以进行进一步配置 - //.whereAny("a", "b").exist(); 这个是只要存在任意一个指定的头部Key就行 - //.whereAll("a", "b").exist(); 这个是必须存在所有指定的的头部Key - .where("test").matches("hello"); //比如我们现在需要消息的头部信息中包含test,并且值为hello才能转发给我们的消息队列 - //.whereAny(Collections.singletonMap("test", "hello")).match(); 传入Map也行,批量指定键值对 - } -} -``` - -现在我们来启动一下试试看: - -![image-20220421110926077](https://tva1.sinaimg.cn/large/e6c9d24ely1h1h62dn6msj21zk0moq4k.jpg) - -结果发现,消息可以成功发送到消息队列,这就是使用头部信息进行路由。 - -这样,我们就介绍完了所有四种类型的交换机。 - -### 集群搭建 - -前面我们对于RabbitMQ的相关内容已经基本讲解完毕了,最后我们来尝试搭建一个集群,让RabbitMQ之间进行数据复制(镜像模式)稍微有点麻烦,跟着视频走吧。 - -可能会用到的一些命令: - -```sh -sudo rabbitmqctl stop_app -sudo rabbitmqctl join_cluster rabbit@ubuntu-server -sudo rabbitmqctl start_app -``` - -实现复制即可。 - -*** - -## SpringCloud 消息组件 - -前面我们已经学习了如何使用RabbitMQ消息队列,接着我们来简单介绍一下SpringCloud为我们提供的一些消息组件。 - -### SpringCloud Stream - -**官方文档:**https://docs.spring.io/spring-cloud-stream/docs/3.2.2/reference/html/ - -前面我们介绍了RabbitMQ,了解了消息队列相关的一些操作,但是可能我们会遇到不同的系统在用不同的消息队列,比如系统A用的Kafka、系统B用的RabbitMQ,但是我们现在又没有学习过Kafka,那么怎么办呢?有没有一种方式像JDBC一样,我们只需要关心SQL和业务本身,而不用关心数据库的具体实现呢? - -SpringCloud Stream能够做到,它能够屏蔽底层实现,我们使用统一的消息队列操作方式就能操作多种不同类型的消息队列。 - -![image-20220421225215709](https://tva1.sinaimg.cn/large/e6c9d24ely1h1hqdnqlqsj21iu0l0myd.jpg) - -它屏蔽了RabbitMQ底层操作,让我们使用统一的Input和Output形式,以Binder为中间件,这样就算我们切换了不同的消息队列,也无需修改代码,而具体某种消息队列的底层实现是交给Stream在做的。 - -这里我们创建一个新的项目来测试一下: - -![image-20220421215534386](https://tva1.sinaimg.cn/large/e6c9d24ely1h1hoqprxzxj21mm0bmtbx.jpg) - -依赖如下: - -```xml - - org.springframework.cloud - spring-cloud-dependencies - 2021.0.1 - pom - import - -``` - -```xml - - - - org.springframework.cloud - spring-cloud-starter-stream-rabbit - - - - org.springframework.boot - spring-boot-starter-web - - -``` - -首先我们来编写一下生产者,首先是配置文件: - -```yaml -server: - port: 8001 -spring: - cloud: - stream: - binders: #此处配置要绑定的rabbitmq的服务信息 - local-server: #绑定名称,随便起一个就行 - type: rabbit #消息组件类型,这里使用的是RabbitMQ,就填写rabbit - environment: #服务器相关信息,按照下面的方式填写就行,爆红别管 - spring: - rabbitmq: - host: 192.168.0.6 - port: 5672 - username: admin - password: admin - virtual-host: /test - bindings: - test-out-0: - destination: test.exchange -``` - -接着我们来编写一个Controller,一会访问一次这个接口,就向消息队列发送一个数据: - -```java -@RestController -public class PublishController { - - @Resource - StreamBridge bridge; //通过bridge来发送消息 - - @RequestMapping("/publish") - public String publish(){ - //第一个参数其实就是RabbitMQ的交换机名称(数据会发送给这个交换机,到达哪个消息队列,不由我们决定) - //这个交换机的命名稍微有一些规则: - //输入: <名称> + -in- + - //输出: <名称> + -out- + - //这里我们使用输出的方式,来将数据发送到消息队列,注意这里的名称会和之后的消费者Bean名称进行对应 - bridge.send("test-out-0", "HelloWorld!"); - return "消息发送成功!"+new Date(); - } -} -``` - -现在我们来将生产者启动一下,访问一下接口: - -![image-20220421220955906](https://tva1.sinaimg.cn/large/e6c9d24ely1h1hp5m6t34j21fk06ggmc.jpg) - -可以看到消息成功发送,我们来看看RabbitMQ这边的情况: - -![image-20220421221027145](https://tva1.sinaimg.cn/large/e6c9d24ely1h1hp65svdxj21bg02it8r.jpg) - -新增了一个`test-in-0`交换机,并且此交换机是topic类型的: - -![image-20220421221107547](https://tva1.sinaimg.cn/large/e6c9d24ely1h1hp6umeypj218w07a0st.jpg) - -但是目前没有任何队列绑定到此交换机上,因此我们刚刚发送的消息实际上是没有给到任何队列的。 - -接着我们来编写一下消费者,消费者的编写方式比较特别,只需要定义一个Consumer就可以了,其他配置保持一致: - -```java -@Component -public class ConsumerComponent { - - @Bean("test") //注意这里需要填写我们前面交换机名称中"名称",这样生产者发送的数据才会正确到达 - public Consumer consumer(){ - return System.out::println; - } -} -``` - -配置中需要修改一下目标交换机: - -```yaml -server: - port: 8002 -spring: - cloud: - stream: - ... - bindings: - #因为消费者是输入,默认名称为 方法名-in-index,这里我们将其指定为我们刚刚定义的交换机 - test-in-0: - destination: test.exchange -``` - -接着我们直接启动就可以了,可以看到启动之后,自动为我们创建了一个新的队列: - -![image-20220421221733723](https://tva1.sinaimg.cn/large/e6c9d24ely1h1hpdjthrqj21sy05mdgw.jpg) - -而这个队列实际上就是我们消费者等待数据到达的队列: - -![image-20220421221807577](https://tva1.sinaimg.cn/large/e6c9d24ely1h1hpe5c8o3j21q00h4q4p.jpg) - -可以看到当前队列直接绑定到了我们刚刚创建的交换机上,并且`routingKey`是直接写的`#`,也就是说一会消息会直接过来。 - -现在我们再来访问一些消息发送接口: - -![image-20220421221938730](https://tva1.sinaimg.cn/large/e6c9d24ely1h1hpfpyn57j212m05y3z4.jpg) - -![image-20220421221952663](https://tva1.sinaimg.cn/large/e6c9d24ely1h1hpfyqu94j21na09kwg3.jpg) - -可以看到消费者成功地进行消费了: - -![image-20220421222011924](https://tva1.sinaimg.cn/large/e6c9d24ely1h1hpgb6zgej218605qq4d.jpg) - -这样,我们就通过使用SpringCloud Stream来屏蔽掉底层RabbitMQ来直接进行消息的操作了。 - -### SpringCloud Bus - -**官方文档:**https://cloud.spring.io/spring-cloud-bus/reference/html/ - -实际上它就相当于是一个消息总线,可用于向各个服务广播某些状态的更改(比如云端配置更改,可以结合Config组件实现动态更新配置,当然我们前面学习的Nacos其实已经包含这个功能了)或其他管理指令。 - -这里我们也是简单使用一下吧,Bus需要基于一个具体的消息队列实现,比如RabbitMQ或是Kafka,这里我们依然使用RabbitMQ。 - -我们将最开始的微服务拆分项目继续使用,比如现在我们希望借阅服务的某个接口调用时,能够给用户服务和图书服务发送一个通知,首先是依赖: - -```xml - - org.springframework.cloud - spring-cloud-starter-bus-amqp - - - - org.springframework.boot - spring-boot-starter-actuator - -``` - -接着我们只需要在配置文件中将RabbitMQ的相关信息配置: - -```yaml -spring: - rabbitmq: - addresses: 192.168.0.6 - username: admin - password: admin - virtual-host: /test -management: - endpoints: - web: - exposure: - include: "*" #暴露端点,一会用于提醒刷新 -``` - -然后启动我们的三个服务器,可以看到在管理面板中: - -![image-20220421232118952](https://tva1.sinaimg.cn/large/e6c9d24ely1h1hr7w6mluj210m01qq2x.jpg) - -新增了springCloudBug这样一个交换机,并且: - -![image-20220421232146646](https://tva1.sinaimg.cn/large/e6c9d24ely1h1hr8d3b38j21uo084411.jpg) - -自动生成了各自的消息队列,这样就可以监听并接收到消息了。 - -现在我们访问一个端口: - -![image-20220421233200950](https://tva1.sinaimg.cn/large/e6c9d24ely1h1hrj15tx4j227m0k0gqo.jpg) - -此端口是用于通知别人进行刷新,可以看到调用之后,消息队列中成功出现了一次消费: - -![image-20220421233302328](https://tva1.sinaimg.cn/large/e6c9d24ely1h1hrk3h5xrj21rk0aegna.jpg) - -现在我们结合之前使用的Config配置中心,来看看是不是可以做到通知之后所有的配置动态刷新了。 diff --git a/青空笔记/SpringCloud笔记/SpringCould笔记(二).md b/青空笔记/SpringCloud笔记/SpringCould笔记(二).md deleted file mode 100644 index 3ef8a10..0000000 --- a/青空笔记/SpringCloud笔记/SpringCould笔记(二).md +++ /dev/null @@ -1,1910 +0,0 @@ -![image-20220326001448808](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ml18j1h6j21v40lkgob.jpg) - -# 微服务进阶 - -前面我们了解了微服务的一套解决方案,但是它是基于Netflix的解决方案,实际上我们发现,很多框架都已经停止维护了,来看看目前我们所认识到的SpringCloud各大组件的维护情况: - -* **注册中心:**Eureka(属于*Netflix*,2.x版本不再开源,1.x版本仍在更新) -* **服务调用:**Ribbon(属于*Netflix*,停止更新,已经彻底被移除)、SpringCloud Loadbalancer(属于*SpringCloud*官方,目前的默认方案) -* **服务降级:**Hystrix(属于*Netflix*,停止更新,已经彻底被移除) -* **路由网关:**Zuul(属于*Netflix*,停止更新,已经彻底被移除)、Gateway(属于*SpringCloud*官方,推荐方案) -* **配置中心:**Config(属于*SpringCloud*官方) - -可见,我们之前使用的整套解决方案中,超过半数的组件都已经处于不可用状态,并且部分组件都是SpringCloud官方出手提供框架进行解决,因此,寻找一套更好的解决方案势在必行,也就引出了我们本章的主角:**SpringCloud Alibaba** - -阿里巴巴作为业界的互联网大厂,给出了一套全新的解决方案,官方网站(中文):https://spring-cloud-alibaba-group.github.io/github-pages/2021/zh-cn/index.html - -> Spring Cloud Alibaba 致力于提供微服务开发的一站式解决方案。此项目包含开发分布式应用服务的必需组件,方便开发者通过 Spring Cloud 编程模型轻松使用这些组件来开发分布式应用服务。 -> -> 依托 Spring Cloud Alibaba,您只需要添加一些注解和少量配置,就可以将 Spring Cloud 应用接入阿里分布式应用解决方案,通过阿里中间件来迅速搭建分布式应用系统。 - -目前 Spring Cloud Alibaba 提供了如下功能: - -1. **服务限流降级**:支持 WebServlet、WebFlux, OpenFeign、RestTemplate、Dubbo 限流降级功能的接入,可以在运行时通过控制台实时修改限流降级规则,还支持查看限流降级 Metrics 监控。 -2. **服务注册与发现**:适配 Spring Cloud 服务注册与发现标准,默认集成了 Ribbon 的支持。 -3. **分布式配置管理**:支持分布式系统中的外部化配置,配置更改时自动刷新。 -4. **Rpc服务**:扩展 Spring Cloud 客户端 RestTemplate 和 OpenFeign,支持调用 Dubbo RPC 服务 -5. **消息驱动能力**:基于 Spring Cloud Stream 为微服务应用构建消息驱动能力。 -6. **分布式事务**:使用 @GlobalTransactional 注解, 高效并且对业务零侵入地解决分布式事务问题。 -7. **阿里云对象存储**:阿里云提供的海量、安全、低成本、高可靠的云存储服务。支持在任何应用、任何时间、任何地点存储和访问任意类型的数据。 -8. **分布式任务调度**:提供秒级、精准、高可靠、高可用的定时(基于 Cron 表达式)任务调度服务。同时提供分布式的任务执行模型,如网格任务。网格任务支持海量子任务均匀分配到所有 Worker(schedulerx-client)上执行。 -9. **阿里云短信服务**:覆盖全球的短信服务,友好、高效、智能的互联化通讯能力,帮助企业迅速搭建客户触达通道。 - -可以看到,SpringCloudAlibaba实际上是对我们的SpringCloud组件增强功能,是SpringCloud的增强框架,可以兼容SpringCloud原生组件和SpringCloudAlibaba的组件。 - -开始学习之前,把我们之前打包好的拆分项目解压,我们将基于它进行讲解。 - -*** - -![image-20220326110940692](https://tva1.sinaimg.cn/large/e6c9d24ely1h0n3ym3j2sj20pg02qa9x.jpg) - -## Nacos 更加全能的注册中心 - -Nacos(**Na**ming **Co**nfiguration **S**ervice)是一款阿里巴巴开源的服务注册与发现、配置管理的组件,相当于是Eureka+Config的组合形态。 - -### 安装与部署 - -Nacos服务器是独立安装部署的,因此我们需要下载最新的Nacos服务端程序,下载地址:https://github.com/alibaba/nacos,连不上可以到视频下方云盘中下载。 - -![image-20220326125206549](https://tva1.sinaimg.cn/large/e6c9d24egy1h0n6x7gxp1j22qs0ms0x0.jpg) - -可以看到目前最新的版本是`1.4.3`版本(2022年2月27日发布的),我们直接下载`zip`文件即可。 - -接着我们将文件进行解压,得到以下内容: - -![image-20220326125854416](https://tva1.sinaimg.cn/large/e6c9d24ely1h0n749lottj215a08swfh.jpg) - -我们直接将其拖入到项目文件夹下,便于我们一会在IDEA内部启动,接着添加运行配置: - -![image-20220326130340573](https://tva1.sinaimg.cn/large/e6c9d24ely1h0n798coaxj226a0i279d.jpg) - -其中`-m standalone`表示单节点模式,Mac和Linux下记得将解释器设定为`/bin/bash`,由于Nacos在Mac/Linux默认是后台启动模式,我们修改一下它的bash文件,让它变成前台启动,这样IDEA关闭了Nacos就自动关闭了,否则开发环境下很容易忘记关: - -```bash -# 注释掉 nohup $JAVA ${JAVA_OPT} nacos.nacos >> ${BASE_DIR}/logs/start.out 2>&1 & -# 替换成下面的 -$JAVA ${JAVA_OPT} nacos.nacos -``` - -接着我们点击启动: - -![image-20220326132051779](https://tva1.sinaimg.cn/large/e6c9d24ely1h0n7r3rhzqj22o80jw0wb.jpg) - -OK,启动成功,可以看到它的管理页面地址也是给我们贴出来了: http://localhost:8848/nacos/index.html,访问这个地址: - -![image-20220326132157126](https://tva1.sinaimg.cn/large/e6c9d24ely1h0n7s92e4jj21lh0u0whw.jpg) - -默认的用户名和管理员密码都是`nacos`,直接登陆即可,可以看到进入管理页面之后功能也是相当丰富: - -![image-20220326132455674](https://tva1.sinaimg.cn/large/e6c9d24ely1h0n7vcjn7oj22mg0p6dj0.jpg) - -至此,Nacos的安装与部署完成。 - -### 服务注册与发现 - -现在我们要实现基于Nacos的服务注册与发现,那么就需要导入SpringCloudAlibaba相关的依赖,我们在父工程将依赖进行管理: - -```xml - - - - org.mybatis.spring.boot - mybatis-spring-boot-starter - 2.2.0 - - - - - org.springframework.cloud - spring-cloud-dependencies - 2021.0.1 - pom - import - - - - - com.alibaba.cloud - spring-cloud-alibaba-dependencies - 2021.0.1.0 - pom - import - - - -``` - -接着我们就可以在子项目中添加服务发现依赖了,比如我们以图书服务为例: - -```xml - - com.alibaba.cloud - spring-cloud-starter-alibaba-nacos-discovery - -``` - -和注册到Eureka一样,我们也需要在配置文件中配置Nacos注册中心的地址: - -```yaml -server: - # 之后所有的图书服务节点就81XX端口 - port: 8101 -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://cloudstudy.mysql.cn-chengdu.rds.aliyuncs.com:3306/cloudstudy - username: test - password: 123456 - # 应用名称 bookservice - application: - name: bookservice - cloud: - nacos: - discovery: - # 配置Nacos注册中心地址 - server-addr: localhost:8848 -``` - -接着启动我们的图书服务,可以在Nacos的服务列表中找到: - -![image-20220326140130226](https://tva1.sinaimg.cn/large/e6c9d24egy1h0n8xfhqcaj22lw0egdih.jpg) - -按照同样的方法,我们接着将另外两个服务也注册到Nacos中: - -![image-20220326140618708](https://tva1.sinaimg.cn/large/e6c9d24egy1h0n92f2wthj22d00ckdi1.jpg) - -接着我们使用OpenFeign,实现服务发现远程调用以及负载均衡,导入依赖: - -```xml - - org.springframework.cloud - spring-cloud-starter-openfeign - - - - org.springframework.cloud - spring-cloud-starter-loadbalancer - -``` - -编写接口: - -```java -@FeignClient("userservice") -public interface UserClient { - - @RequestMapping("/user/{uid}") - User getUserById(@PathVariable("uid") int uid); -} -``` - -```java -@FeignClient("bookservice") -public interface BookClient { - - @RequestMapping("/book/{bid}") - Book getBookById(@PathVariable("bid") int bid); -} -``` - -```java -@Service -public class BorrowServiceImpl implements BorrowService{ - - @Resource - BorrowMapper mapper; - - @Resource - UserClient userClient; - - @Resource - BookClient bookClient; - - @Override - public UserBorrowDetail getUserBorrowDetailByUid(int uid) { - List borrow = mapper.getBorrowsByUid(uid); - User user = userClient.getUserById(uid); - List bookList = borrow - .stream() - .map(b -> bookClient.getBookById(b.getBid())) - .collect(Collectors.toList()); - return new UserBorrowDetail(user, bookList); - } -} -``` - -```java -@EnableFeignClients -@SpringBootApplication -public class BorrowApplication { - public static void main(String[] args) { - SpringApplication.run(BorrowApplication.class, args); - } -} -``` - -接着我们进行测试: - -![image-20220326142331199](https://tva1.sinaimg.cn/large/e6c9d24ely1h0n9kaxje1j228c074q4h.jpg) - -测试正常,可以自动发现服务,接着我们来多配置几个实例,去掉图书服务和用户服务的端口配置: - -![image-20220326142751398](https://tva1.sinaimg.cn/large/e6c9d24ely1h0n9otepuuj21lk0lin0g.jpg) - -然后我们在图书服务和用户服务中添加一句打印方便之后查看: - -```java -@RequestMapping("/user/{uid}") -public User findUserById(@PathVariable("uid") int uid){ - System.out.println("调用用户服务"); - return service.getUserById(uid); -} -``` - -现在将全部服务启动: -![image-20220326142953904](https://tva1.sinaimg.cn/large/e6c9d24ely1h0n9qxup53j223g0eqaix.jpg) - -可以看到Nacos中的实例数量已经显示为`2`: - -![image-20220326143017054](https://tva1.sinaimg.cn/large/e6c9d24ely1h0n9rc8v0wj22cg0g6goj.jpg) - -接着我们调用借阅服务,看看能否负载均衡远程调用: - -![image-20220326143058939](https://tva1.sinaimg.cn/large/e6c9d24ely1h0n9s2ioeqj225o07ytaa.jpg) - -![image-20220326143122333](https://tva1.sinaimg.cn/large/e6c9d24ely1h0n9sh0wr0j21x20gswne.jpg) - -OK,负载均衡远程调用没有问题,这样我们就实现了基于Nacos的服务的注册与发现,实际上大致流程与Eureka一致。 - -值得注意的是,Nacos区分了临时实例和非临时实例: - -![image-20220326155010841](https://tva1.sinaimg.cn/large/e6c9d24ely1h0nc2h4gy2j22ay072q44.jpg) - -那么临时和非临时有什么区别呢? - -* 临时实例:和Eureka一样,采用心跳机制向Nacos发送请求保持在线状态,一旦心跳停止,代表实例下线,不保留实例信息。 -* 非临时实例:由Nacos主动进行联系,如果连接失败,那么不会移除实例信息,而是将健康状态设定为false,相当于会对某个实例状态持续地进行监控。 - -我们可以通过配置文件进行修改临时实例: - -```yaml -spring: - application: - name: borrowservice - cloud: - nacos: - discovery: - server-addr: localhost:8848 - # 将ephemeral修改为false,表示非临时实例 - ephemeral: false -``` - -接着我们在Nacos中查看,可以发现实例已经不是临时的了: - -![image-20220326155554821](https://tva1.sinaimg.cn/large/e6c9d24ely1h0nc8g1llrj22be06iq3y.jpg) - -如果这时我们关闭此实例,那么会变成这样: - -![image-20220326155633190](https://tva1.sinaimg.cn/large/e6c9d24ely1h0nc93w8zlj22bq06mmy5.jpg) - -只是将健康状态变为false,而不会删除实例的信息。 - -### 集群分区 - -实际上集群分区概念在之前的Eureka中也有出现,比如: - -```yaml -eureka: - client: - fetch-registry: false - register-with-eureka: false - service-url: - defaultZone: http://localhost:8888/eureka - # 这个defaultZone是个啥玩意,为什么要用这个名称?为什么要要用这样的形式来声明注册中心? -``` - -在一个分布式应用中,相同服务的实例可能会在不同的机器、位置上启动,比如我们的用户管理服务,可能在成都有1台服务器部署、重庆有一台服务器部署,而这时,我们在成都的服务器上启动了借阅服务,那么如果我们的借阅服务现在要调用用户服务,就应该优先选择同一个区域的用户服务进行调用,这样会使得响应速度更快。 - -![image-20220326150024118](https://tva1.sinaimg.cn/large/e6c9d24ely1h0namonso5j21em0bcgnr.jpg) - -因此,我们可以对部署在不同机房的服务进行分区,可以看到实例的分区是默认: - -![image-20220326150136538](https://tva1.sinaimg.cn/large/e6c9d24ely1h0nanxl0kkj22cm0hawh5.jpg) - -我们可以直接在配置文件中进行修改: - -```yaml -spring: - application: - name: borrowservice - cloud: - nacos: - discovery: - server-addr: localhost:8848 - # 修改为重庆地区的集群 - cluster-name: Chongqing -``` - -当然由于我们这里使用的是不同的启动配置,直接在启动配置中添加环境变量`spring.cloud.nacos.discovery.cluster-name`也行,这里我们将用户服务和图书服务两个区域都分配一个,借阅服务就配置为成都地区: - -![image-20220326150518357](https://tva1.sinaimg.cn/large/e6c9d24ely1h0nars805bj216c08ot9x.jpg) - -修改完成之后,我们来尝试重新启动一下(Nacos也要重启),观察Nacos中集群分布情况: - -![image-20220326150956937](https://tva1.sinaimg.cn/large/e6c9d24ely1h0nawm1jraj22ck0u0wij.jpg) - -可以看到现在有两个集群,并且都有一个实例正在运行。我们接着去调用借阅服务,但是发现并没有按照区域进行优先调用,而依然使用的是轮询模式的负载均衡调用。 - -我们必须要提供Nacos的负载均衡实现才能开启区域优先调用机制,只需要在配制文件中进行修改即可: - -```yaml -spring: - application: - name: borrowservice - cloud: - nacos: - discovery: - server-addr: localhost:8848 - cluster-name: Chengdu - # 将loadbalancer的nacos支持开启,集成Nacos负载均衡 - loadbalancer: - nacos: - enabled: true -``` - -现在我们重启借阅服务,会发现优先调用的是同区域的用户和图书服务,现在我们可以将成都地区的服务下线: - -![image-20220326153002500](https://tva1.sinaimg.cn/large/e6c9d24ely1h0nbhisubdj22d20ekjtk.jpg) - -可以看到,在下线之后,由于本区域内没有可用服务了,借阅服务将会调用重庆区域的用户服务。 - -除了根据区域优先调用之外,同一个区域内的实例也可以单独设置权重,Nacos会优先选择权重更大的实例进行调用,我们可以直接在管理页面中进行配置: - -![image-20220326152659294](https://tva1.sinaimg.cn/large/e6c9d24ely1h0nbecloluj22co0n841e.jpg) - -或是在配置文件中进行配置: - -```yml -spring: - application: - name: borrowservice - cloud: - nacos: - discovery: - server-addr: localhost:8848 - cluster-name: Chengdu - # 权重大小,越大越优先调用,默认为1 - weight: 0.5 -``` - -通过配置权重,某些性能不太好的机器就能够更少地被使用,而更多的使用那些网络良好性能更高的主机上的实例。 - -### 配置中心 - -前面我们学习了SpringCloud Config,我们可以通过配置服务来加载远程配置,这样我们就可以在远端集中管理配置文件。 - -实际上我们可以在`bootstrap.yml`中配置远程配置文件获取,然后再进入到配置文件加载环节,而Nacos也支持这样的操作,使用方式也比较类似,比如我们现在想要将借阅服务的配置文件放到Nacos进行管理,那么这个时候就需要在Nacos中创建配置文件: - -![image-20220326161111523](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ncoc8fwqj22mq0gidi8.jpg) - -将借阅服务的配置文件全部(当然正常情况下是不会全部CV的,只会复制那些需要经常修改的部分,这里为了省事就直接全部CV了)复制过来,注意**Data ID**的格式跟我们之前一样,`应用名称-环境.yml`,如果只编写应用名称,那么代表此配置文件无论在什么环境下都会使用,然后每个配置文件都可以进行分组,也算是一种分类方式: - -![image-20220326162108899](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ncyoyq7ij21sw0u0n0o.jpg) - -完成之后点击发布即可: - -![image-20220326162122134](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ncyx3tjnj22cs0bedhs.jpg) - -然后在项目中导入依赖: - -```xml - - org.springframework.cloud - spring-cloud-starter-bootstrap - - - com.alibaba.cloud - spring-cloud-starter-alibaba-nacos-config - -``` - -接着我们在借阅服务中添加`bootstrap.yml`文件: - -```yaml -spring: - application: - # 服务名称和配置文件保持一致 - name: borrowservice - profiles: - # 环境也是和配置文件保持一致 - active: dev - cloud: - nacos: - config: - # 配置文件后缀名 - file-extension: yml - # 配置中心服务器地址,也就是Nacos地址 - server-addr: localhost:8848 -``` - -现在我们启动服务试试看: - -![image-20220326163449032](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ndcx7e8sj22lw0eiwlz.jpg) - -可以看到成功读取配置文件并启动了,实际上使用上来说跟之前的Config是基本一致的。 - -Nacos还支持配置文件的热更新,比如我们在配置文件中添加了一个属性,而这个时候可能需要实时修改,并在后端实时更新,那么这种该怎么实现呢?我们创建一个新的Controller: - -```java -@RestController -public class TestController { - - @Value("${test.txt}") //我们从配置文件中读取test.txt的字符串值,作为test接口的返回值 - String txt; - - @RequestMapping("/test") - public String test(){ - return txt; - } -} -``` - -我们修改一下配置文件,然后重启服务器: - -![image-20220326164209154](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ndkk16naj22bs0lstc8.jpg) - -可以看到已经可以正常读取了: - -![image-20220326164306032](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ndljhdc8j217e06it93.jpg) - -现在我们将配置文件的值进行修改: - -![image-20220326164531412](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ndo2g2ouj210e04mmxb.jpg) - -再次访问接口,会发现没有发生变化: - -![image-20220326164549862](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ndodjuhkj2126068jrq.jpg) - -但是后台是成功检测到值更新了,但是值却没改变: - -![image-20220326164645791](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ndpco63fj222e03cq4n.jpg) - -那么如何才能实现配置热更新呢?我们可以像下面这样: - -```java -@RestController -@RefreshScope //添加此注解就能实现自动刷新了 -public class TestController { - - @Value("${test.txt}") - String txt; - - @RequestMapping("/test") - public String test(){ - return txt; - } -} -``` - -重启服务器,再次重复上述实验,成功。 - -### 命名空间 - -我们还可以将配置文件或是服务实例划分到不同的命名空间中,其实就是区分开发、生产环境或是引用归属之类的: - -![image-20220326172756819](https://tva1.sinaimg.cn/large/e6c9d24ely1h0new72nlcj22ci0bat9o.jpg) - -这里我们创建一个新的命名空间: - -![image-20220326173744551](https://tva1.sinaimg.cn/large/e6c9d24ely1h0nf6e5ml4j22aa0j875n.jpg) - -可以看到在dev命名空间下,没有任何配置文件和服务: - -![image-20220326175340892](https://tva1.sinaimg.cn/large/e6c9d24ely1h0nfmz4j3gj22mq0j6gom.jpg) - -我们在不同的命名空间下,实例和配置都是相互之间隔离的,我们也可以在配置文件中指定当前的命名空间。 - -### 实现高可用 - -由于Nacos暂不支持Arm架构芯片的Mac集群搭建,本小节用Linxu云主机(Nacos比较吃内存,2个Nacos服务器集群,至少2G内存)环境演示。 - -通过前面的学习,我们已经了解了如何使用Nacos以及Nacos的功能等,最后我们来看看,如果像之前Eureka一样,搭建Nacos集群,实现高可用。 - -官方方案:https://nacos.io/zh-cn/docs/cluster-mode-quick-start.html - -![deployDnsVipMode.jpg](https://nacos.io/img/deployDnsVipMode.jpg) - ->http://ip1:port/openAPI 直连ip模式,机器挂则需要修改ip才可以使用。 -> ->http://SLB:port/openAPI 挂载SLB模式(内网SLB,不可暴露到公网,以免带来安全风险),直连SLB即可,下面挂server真实ip,可读性不好。 -> ->http://nacos.com:port/openAPI 域名 + SLB模式(内网SLB,不可暴露到公网,以免带来安全风险),可读性好,而且换ip方便,推荐模式 - -我们来看看它的架构设计,它推荐我们在所有的Nacos服务端之前建立一个负载均衡,我们通过访问负载均衡服务器来间接访问到各个Nacos服务器。实际上就,是比如有三个Nacos服务器做集群,但是每个服务不可能把每个Nacos都去访问一次进行注册,实际上只需要在任意一台Nacos服务器上注册即可,Nacos服务器之间会自动同步信息,但是如果我们随便指定一台Nacos服务器进行注册,如果这台Nacos服务器挂了,但是其他Nacos服务器没挂,这样就没办法完成注册了,但是实际上整个集群还是可用的状态。 - -所以这里就需要在所有Nacos服务器之前搭建一个SLB(服务器负载均衡),这样就可以避免上面的问题了。但是我们知道,如果要实现外界对服务访问的负载均衡,我们就得用比如之前说到的Gateway来实现,而这里实际上我们可以用一个更加方便的工具:Nginx,来实现(之前我们没讲过,但是使用起来很简单,放心后面会带着大家使用) - -关于SLB最上方还有一个DNS(我们在`计算机网络`这门课程中学习过),这个是因为SLB是裸IP,如果SLB服务器修改了地址,那么所有微服务注册的地址也得改,所以这里是通过加域名,通过域名来访问,让DNS去解析真实IP,这样就算改变IP,只需要修改域名解析记录即可,域名地址是不会变化的。 - -最后就是Nacos的数据存储模式,在单节点的情况下,Nacos实际上是将数据存放在自带的一个嵌入式数据库中: - -![image-20220326222343802](https://tva1.sinaimg.cn/large/e6c9d24ely1h0nng0ithgj215i0b875m.jpg) - -而这种模式只适用于单节点,在多节点集群模式下,肯定是不能各存各的,所以,Nacos提供了MySQL统一存储支持,我们只需要让所有的Nacos服务器连接MySQL进行数据存储即可,官方也提供好了SQL文件。 - -现在就可以开始了,第一步,我们直接导入数据库即可,文件在conf目录中: - -![image-20220326222728745](https://tva1.sinaimg.cn/large/e6c9d24egy1h0nnjw4980j2154090jtf.jpg) - -我们来将其导入到数据库,可以看到生成了很多的表: - -![image-20220326222957239](https://tva1.sinaimg.cn/large/e6c9d24egy1h0nnmgm3g0j21580cqgod.jpg) - -然后我们来创建两个Nacos服务器,做一个迷你的集群,这里使用`scp`命令将nacos服务端上传到Linux服务器(注意需要提前安装好JRE 8或更高版本的环境): - -![image-20220327115901662](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ob09uny0j21880303z6.jpg) - -解压之后,我们对其配置文件进行修改,首先是`application.properties`配置文件,修改以下内容,包括MySQL服务器的信息: - -```properties -### Default web server port: -server.port=8801 - -#*************** Config Module Related Configurations ***************# -### If use MySQL as datasource: -spring.datasource.platform=mysql - -### Count of DB: -db.num=1 - -### Connect URL of DB: -db.url.0=jdbc:mysql://cloudstudy.mysql.cn-chengdu.rds.aliyuncs.com:3306/nacos?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC -db.user.0=nacos -db.password.0=nacos -``` - -然后修改集群配置,这里需要重命名一下: - -![image-20220327120219022](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ob3otjt5j20yc01iglw.jpg) - -端口记得使用内网IP地址: - -![image-20220327142541523](https://tva1.sinaimg.cn/large/e6c9d24ely1h0of8v91lzj211y05mjrr.jpg) - -最后我们修改一下Nacos的内存分配以及前台启动,直接修改`startup.sh`文件(内存有限,玩不起高的): - -![image-20220327125049013](https://tva1.sinaimg.cn/large/e6c9d24ely1h0oci5kyrzj218m0acn0i.jpg) - -保存之后,将nacos复制一份,并将端口修改为8802,接着启动这两个Nacos服务器。 - -![image-20220327125201913](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ocjew7soj218408ktb8.jpg) - -然后我们打开管理面板,可以看到两个节点都已经启动了: - -![image-20220327125232238](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ocjy2a7dj22mo0j6jtq.jpg) - -这样,我们第二步就完成了,接着我们需要添加一个SLB,这里我们用Nginx做反向代理: - -> *Nginx* (engine x) 是一个高性能的[HTTP](https://baike.baidu.com/item/HTTP)和[反向代理](https://baike.baidu.com/item/反向代理/7793488)web服务器,同时也提供了IMAP/POP3/SMTP服务。它相当于在内网与外网之间形成一个网关,所有的请求都可以由Nginx服务器转交给内网的其他服务器。 - -这里我们直接安装: - -```sh - sudo apt install nginx -``` - -可以看到直接请求80端口之后得到,表示安装成功: - -![image-20220327130009391](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ocrv94q3j22mi0eidi8.jpg) - -现在我们需要让其代理我们刚刚启动的两个Nacos服务器,我们需要对其进行一些配置。配置文件位于`/etc/nginx/nginx.conf`,添加以下内容: - -```conf -#添加我们在上游刚刚创建好的两个nacos服务器 -upstream nacos-server { - server 10.0.0.12:8801; - server 10.0.0.12:8802; -} - -server { - listen 80; - server_name 1.14.121.107; - - location /nacos { - proxy_pass http://nacos-server; - } -} -``` - -重启Nginx服务器,成功连接: - -![image-20220327144441878](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ofsnlufwj21wa0u0wis.jpg) - -然后我们将所有的服务全部修改为云服务器上Nacos的地址,启动试试看。 - -![image-20220327145216771](https://tva1.sinaimg.cn/large/e6c9d24ely1h0og0j7z8dj22mg0l0gpk.jpg) - -这样,我们就搭建好了Nacos集群。 - -*** - -![image-20220327153016414](https://tva1.sinaimg.cn/large/e6c9d24ely1h0oh42qki6j21ns0aw0th.jpg) - -## Sentinel 流量防卫兵 - -**注意:**这一章有点小绕,思路理清。 - -经过之前的学习,我们了解了微服务存在的雪崩问题,也就是说一个微服务出现问题,有可能导致整个链路直接不可用,这种时候我们就需要进行及时的熔断和降级,这些策略,我们之前通过使用Hystrix来实现。 - -SpringCloud Alibaba也有自己的微服务容错组件,但是它相比Hystrix更加的强大。 - -> 随着微服务的流行,服务和服务之间的稳定性变得越来越重要。Sentinel 以流量为切入点,从流量控制、熔断降级、系统负载保护等多个维度保护服务的稳定性。 - -Sentinel 具有以下特征: - -- **丰富的应用场景**:Sentinel 承接了阿里巴巴近 10 年的双十一大促流量的核心场景,例如秒杀(即突发流量控制在系统容量可以承受的范围)、消息削峰填谷、集群流量控制、实时熔断下游不可用应用等。 -- **完备的实时监控**:Sentinel 同时提供实时的监控功能。您可以在控制台中看到接入应用的单台机器秒级数据,甚至 500 台以下规模的集群的汇总运行情况。 -- **广泛的开源生态**:Sentinel 提供开箱即用的与其它开源框架/库的整合模块,例如与 Spring Cloud、Apache Dubbo、gRPC、Quarkus 的整合。您只需要引入相应的依赖并进行简单的配置即可快速地接入 Sentinel。同时 Sentinel 提供 Java/Go/C++ 等多语言的原生实现。 -- **完善的 SPI 扩展机制**:Sentinel 提供简单易用、完善的 SPI 扩展接口。您可以通过实现扩展接口来快速地定制逻辑。例如定制规则管理、适配动态数据源等。 - -### 安装与部署 - -和Nacos一样,它是独立安装和部署的,下载地址:https://github.com/alibaba/Sentinel/releases - -![image-20220327154616456](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ohkq2u65j22qy0nogpw.jpg) - -注意下载下来之后是一个`jar`文件(其实就是个SpringBoot项目),我们需要在IDEA中添加一些运行配置: - -![image-20220327163002399](https://tva1.sinaimg.cn/large/e6c9d24ely1h0oiu9a58tj22ae0dgdjy.jpg) - -接着就可以直接启动啦,当然默认端口占用8080,如果需要修改,可以添加环境变量: - -![image-20220327163110733](https://tva1.sinaimg.cn/large/e6c9d24ely1h0oivfso85j218e04cmxt.jpg) - -启动之后,就可以访问到Sentinel的监控页面了,用户名和密码都是`sentinel`,地址:http://localhost:8858/#/dashboard - -![image-20220327163206117](https://tva1.sinaimg.cn/large/e6c9d24ely1h0oiweerr4j22mg0dwwfm.jpg) - -这样就成功开启监控页面了,接着我们需要让我们的服务连接到Sentinel控制台,老规矩,导入依赖: - -```xml - - com.alibaba.cloud - spring-cloud-starter-alibaba-sentinel - -``` - -然后在配置文件中添加Sentinel相关信息(实际上Sentinel是本地在进行管理,但是我们可以连接到监控页面,这样就可以图形化操作了): - -```yaml -spring: - application: - name: userservice - cloud: - nacos: - discovery: - server-addr: localhost:8848 - sentinel: - transport: - # 添加监控页面地址即可 - dashboard: localhost:8858 -``` - -现在启动我们的服务,然后访问一次服务,这样Sentinel中就会存在信息了(懒加载机制,不会一上来就加载): - -![image-20220327164111325](https://tva1.sinaimg.cn/large/e6c9d24ely1h0oj5uuyfwj21ny074js0.jpg) - -![image-20220327164336877](https://tva1.sinaimg.cn/large/e6c9d24ely1h0oj8drp9hj223m0u00wi.jpg) - -现在我们就可以在Sentinel控制台中对我们的服务运行情况进行实时监控了,可以看到监控的内容非常的多,包括时间点、QPS(每秒查询率)、响应时间等数据。 - -按照上面的方式,我们将所有的服务全部连接到Sentinel管理面板中。 - -### 流量控制 - -前面我们完成了对Sentinel的搭建与连接,接着我们来看看Sentinel的第一个功能,流量控制。 - -我们的机器不可能无限制的接受和处理客户端的请求,如果不加以限制,当发生高并发情况时,系统资源将很快被耗尽。为了避免这种情况,我们就可以添加流量控制(也可以说是限流)当一段时间内的流量到达一定的阈值的时候,新的请求将不再进行处理,这样不仅可以合理地应对高并发请求,同时也能在一定程度上保护服务器不受到外界的恶意攻击。 - -那么要实现限流,正常情况下,我们该采取什么样的策略呢? - -* 方案一:**快速拒绝**,既然不再接受新的请求,那么我们可以直接返回一个拒绝信息,告诉用户访问频率过高。 -* 方案二:**预热**,依然基于方案一,但是由于某些情况下高并发请求是在某一时刻突然到来,我们可以缓慢地将阈值提高到指定阈值,形成一个缓冲保护。 -* 方案三:**排队等待**,不接受新的请求,但是也不直接拒绝,而是进队列先等一下,如果规定时间内能够执行,那么就执行,要是超时就算了。 - -针对于是否超过流量阈值的判断,这里我们提4种算法: - -1. **漏桶算法** - - 顾名思义,就像一个桶开了一个小孔,水流进桶中的速度肯定是远大于水流出桶的速度的,这也是最简单的一种限流思路: - - ![image-20220327172014949](https://tva1.sinaimg.cn/large/e6c9d24ely1h0okai0crij21om08kgmz.jpg) - - 我们知道,桶是有容量的,所以当桶的容量已满时,就装不下水了,这时就只有丢弃请求了。 - - 利用这种思想,我们就可以写出一个简单的限流算法。 - -2. **令牌桶算法** - - 只能说有点像信号量机制。现在有一个令牌桶,这个桶是专门存放令牌的,每隔一段时间就向桶中丢入一个令牌(速度由我们指定)当新的请求到达时,将从桶中删除令牌,接着请求就可以通过并给到服务,但是如果桶中的令牌数量不足,那么不会删除令牌,而是让此数据包等待。 - - ![image-20220327173323339](https://tva1.sinaimg.cn/large/e6c9d24ely1h0okow2vd5j21lg0gkdid.jpg) - - 可以试想一下,当流量下降时,令牌桶中的令牌会逐渐积累,这样如果突然出现高并发,那么就能在短时间内拿到大量的令牌。 - -3. **固定时间窗口算法** - - 我们可以对某一个时间段内的请求进行统计和计数,比如在`14:15`到`14:16`这一分钟内,请求量不能超过`100`,也就是一分钟之内不能超过`100`次请求,那么就可以像下面这样进行划分: - - ![image-20220327174027199](https://tva1.sinaimg.cn/large/e6c9d24ely1h0okvim48fj219404274n.jpg) - - 虽然这种模式看似比较合理,但是试想一下这种情况: - - * 14:15:59的时候来了100个请求 - * 14:16:01的时候又来了100个请求 - - 出现上面这种情况,符合固定时间窗口算法的规则,所以这200个请求都能正常接受,但是,如果你反应比较快,应该发现了,我们其实希望的是60秒内只有100个请求,但是这种情况却是在3秒内出现了200个请求,很明显已经违背了我们的初衷。 - - 因此,当遇到临界点时,固定时间窗口算法存在安全隐患。 - -4. **滑动时间窗口算法** - - 相对于固定窗口算法,滑动时间窗口算法更加灵活,它会动态移动窗口,重新进行计算: - - ![image-20220327174906227](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ol4irdckj21fs0jggnr.jpg) - - 虽然这样能够避免固定时间窗口的临界问题,但是这样显然是比固定窗口更加耗时的。 - -好了,了解完了我们的限流策略和判定方法之后,我们在Sentinel中进行实际测试一下,打开管理页面的簇点链路模块: - -![image-20220327175131519](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ol71o173j229j0u0444.jpg) - -这里演示对我们的借阅接口进行限流,点击`流控`,会看到让我们添加流控规则: - -* 阈值类型:QPS就是每秒钟的请求数量,并发线程数是按服务当前使用的线程数据进行统计的。 -* 流控模式:当达到阈值时,流控的对象,这里暂时只用直接。 -* 流控效果:就是我们上面所说的三种方案。 - -这里我们选择`QPS`、阈值设定为`1`,流控模式选择`直接`、流控效果选择`快速失败`,可以看到,当我们快速地进行请求时,会直接返回失败信息: - -![image-20220327175821941](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ole5mz4jj21d406it9b.jpg) - -这里各位最好自行尝试一下其他的流控效果,熟悉和加深印象。 - -最后我们来看看这些流控模式有什么区别: - -* 直接:只针对于当前接口。 -* 关联:当其他接口超过阈值时,会导致当前接口被限流。 -* 链路:更细粒度的限流,能精确到具体的方法。 - -我们首先来看看关联,比如现在我们对自带的`/error`接口进行限流: - -![image-20220327182851278](https://tva1.sinaimg.cn/large/e6c9d24egy1h0om9w2tq0j224r0u0gq3.jpg) - -注意限流是作用于关联资源的,一旦发现关联资源超过阈值,那么就会对当前的资源进行限流,我们现在来测试一下,这里使用PostMan的Runner连续对关联资源发起请求: - -![image-20220327183241316](https://tva1.sinaimg.cn/large/e6c9d24egy1h0omdvnxbxj21c00u0goi.jpg) - -开启Postman,然后我们会发现借阅服务已经凉凉: - -![image-20220327183331595](https://tva1.sinaimg.cn/large/e6c9d24egy1h0omeqzhpij215o06c3z3.jpg) - -当我们关闭掉Postman的任务后,恢复正常。 - -最后我们来讲解一下链路模式,它能够更加精准的进行流量控制,链路流控模式指的是,当从指定接口过来的资源请求达到限流条件时,开启限流,这里得先讲解一下`@SentinelResource`的使用。 - -我们可以对某一个方法进行限流控制,无论是谁在何处调用了它,这里需要使用到`@SentinelResource`,一旦方法被标注,那么就会进行监控,比如我们这里创建两个请求映射,都来调用Service的被监控方法: - -```java -@RestController -public class BorrowController { - - @Resource - BorrowService service; - - @RequestMapping("/borrow/{uid}") - UserBorrowDetail findUserBorrows(@PathVariable("uid") int uid){ - return service.getUserBorrowDetailByUid(uid); - } - - @RequestMapping("/borrow2/{uid}") - UserBorrowDetail findUserBorrows2(@PathVariable("uid") int uid){ - return service.getUserBorrowDetailByUid(uid); - } -} -``` - -```java -@Service -public class BorrowServiceImpl implements BorrowService{ - - @Resource - BorrowMapper mapper; - - @Resource - UserClient userClient; - - @Resource - BookClient bookClient; - - @Override - @SentinelResource("getBorrow") //监控此方法,无论被谁执行都在监控范围内,这里给的value是自定义名称,这个注解可以加在任何方法上,包括Controller中的请求映射方法,跟HystrixCommand贼像 - public UserBorrowDetail getUserBorrowDetailByUid(int uid) { - List borrow = mapper.getBorrowsByUid(uid); - User user = userClient.getUserById(uid); - List bookList = borrow - .stream() - .map(b -> bookClient.getBookById(b.getBid())) - .collect(Collectors.toList()); - return new UserBorrowDetail(user, bookList); - } -} -``` - -接着添加配置: - -```yaml -spring: - application: - name: borrowservice - cloud: - sentinel: - transport: - dashboard: localhost:8858 - # 关闭Context收敛,这样被监控方法可以进行不同链路的单独控制 - web-context-unify: false -``` - -然后我们在Sentinel控制台中添加流控规则,注意是针对此方法,可以看到已经自动识别到borrow接口下调用了这个方法: - -![image-20220328112645048](https://tva1.sinaimg.cn/large/e6c9d24ely1h0pfp1fhrcj22bh0u00yg.jpg) - -最后我们在浏览器中对这两个接口都进行测试,会发现,无论请求哪个接口,只要调用了Service中的`getUserBorrowDetailByUid`这个方法,都会被限流。注意限流的形式是后台直接抛出异常,至于怎么处理我们后面再说。 - -那么这个链路选项实际上就是决定只限流从哪个方向来的调用,比如我们只对`borrow2`这个接口对`getUserBorrowDetailByUid`方法的调用进行限流,那么我们就可以为其指定链路: - -![image-20220328112949894](https://tva1.sinaimg.cn/large/e6c9d24ely1h0pfs7hqj4j224s0u0dkb.jpg) - -然后我们会发现,限流效果只对我们配置的链路接口有效,而其他链路是不会被限流的。 - -除了直接对接口进行限流规则控制之外,我们也可以根据当前系统的资源使用情况,决定是否进行限流: - -![image-20220328235217680](https://tva1.sinaimg.cn/large/e6c9d24ely1h0q18q6t5vj22dk0u0q72.jpg) - -系统规则支持以下的模式: - -- **Load 自适应**(仅对 Linux/Unix-like 机器生效):系统的 load1 作为启发指标,进行自适应系统保护。当系统 load1 超过设定的启发值,且系统当前的并发线程数超过估算的系统容量时才会触发系统保护(BBR 阶段)。系统容量由系统的 `maxQps * minRt` 估算得出。设定参考值一般是 `CPU cores * 2.5`。 -- **CPU usage**(1.5.0+ 版本):当系统 CPU 使用率超过阈值即触发系统保护(取值范围 0.0-1.0),比较灵敏。 -- **平均 RT**:当单台机器上所有入口流量的平均 RT 达到阈值即触发系统保护,单位是毫秒。 -- **并发线程数**:当单台机器上所有入口流量的并发线程数达到阈值即触发系统保护。 -- **入口 QPS**:当单台机器上所有入口流量的 QPS 达到阈值即触发系统保护。 - -这里就不进行演示了。 - -### 限流和异常处理 - -现在我们已经了解了如何进行限流操作,那么限流状态下的返回结果该怎么修改呢,我们看到被限流之后返回的是Sentinel默认的数据,现在我们希望自定义改如何操作? - -这里我们先创建好被限流状态下需要返回的内容,定义一个请求映射: - -```java -@RequestMapping("/blocked") -JSONObject blocked(){ - JSONObject object = new JSONObject(); - object.put("code", 403); - object.put("success", false); - object.put("massage", "您的请求频率过快,请稍后再试!"); - return object; -} -``` - -接着我们在配置文件中将此页面设定为限流页面: - -```yaml -spring: - cloud: - sentinel: - transport: - dashboard: localhost:8858 - # 将刚刚编写的请求映射设定为限流页面 - block-page: /blocked -``` - -这样,当被限流时,就会被重定向到指定页面: - -![image-20220328153755461](https://tva1.sinaimg.cn/large/e6c9d24ely1h0pmyc5o1uj21gi06m3zc.jpg) - -那么,对于方法级别的限流呢?经过前面的学习我们知道,当某个方法被限流时,会直接在后台抛出异常,那么这种情况我们该怎么处理呢,比如我们之前在Hystrix中可以直接添加一个替代方案,这样当出现异常时会直接执行我们的替代方法并返回,Sentinel也可以。 - -比如我们还是在`getUserBorrowDetailByUid`方法上进行配置: - -```java -@Override -@SentinelResource(value = "getBorrow", blockHandler = "blocked") //指定blockHandler,也就是被限流之后的替代解决方案,这样就不会使用默认的抛出异常的形式了 -public UserBorrowDetail getUserBorrowDetailByUid(int uid) { - List borrow = mapper.getBorrowsByUid(uid); - User user = userClient.getUserById(uid); - List bookList = borrow - .stream() - .map(b -> bookClient.getBookById(b.getBid())) - .collect(Collectors.toList()); - return new UserBorrowDetail(user, bookList); -} - -//替代方案,注意参数和返回值需要保持一致,并且参数最后还需要额外添加一个BlockException -public UserBorrowDetail blocked(int uid, BlockException e) { - return new UserBorrowDetail(null, Collections.emptyList()); -} -``` - -可以看到,一旦被限流将执行替代方案,最后返回的结果就是: - -![image-20220328154430549](https://tva1.sinaimg.cn/large/e6c9d24ely1h0pn56vzirj217y06kaai.jpg) - -注意`blockHandler`只能处理限流情况下抛出的异常,包括下面即将要介绍的热点参数限流也是同理,如果是方法本身抛出的其他类型异常,不在管控范围内,但是可以通过其他参数进行处理: - -```java -@RequestMapping("/test") -@SentinelResource(value = "test", - fallback = "except", //fallback指定出现异常时的替代方案 - exceptionsToIgnore = IOException.class) //忽略那些异常,也就是说这些异常出现时不使用替代方案 -String test(){ - throw new RuntimeException("HelloWorld!"); -} - -//替代方法必须和原方法返回值和参数一致,最后可以添加一个Throwable作为参数接受异常 -String except(Throwable t){ - return t.getMessage(); -} -``` - -这样,其他的异常也可以有替代方案了: - -![image-20220328161940219](https://tva1.sinaimg.cn/large/e6c9d24ely1h0po5s15rej218u06uwev.jpg) - -特别注意这种方式会在没有配置`blockHandler`的情况下,将Sentinel机制内(也就是限流的异常)的异常也一并处理了,如果配置了`blockHandler`,那么在出现限流时,依然只会执行`blockHandler`指定的替代方案(因为限流是在方法执行之前进行的) - -### 热点参数限流 - -我们还可以对某一热点数据进行精准限流,比如在某一时刻,不同参数被携带访问的频率是不一样的: - -* http://localhost:8301/test?a=10 访问100次 -* http://localhost:8301/test?b=10 访问0次 -* http://localhost:8301/test?c=10 访问3次 - -由于携带参数`a`的请求比较多,我们就可以只对携带参数`a`的请求进行限流。 - -这里我们创建一个新的测试请求映射: - -```java -@RequestMapping("/test") -@SentinelResource("test") //注意这里需要添加@SentinelResource才可以,用户资源名称就使用这里定义的资源名称 -String findUserBorrows2(@RequestParam(value = "a", required = false) int a, - @RequestParam(value = "b", required = false) int b, - @RequestParam(value = "c",required = false) int c) { - return "请求成功!a = "+a+", b = "+b+", c = "+c; -} -``` - -启动之后,我们在Sentinel里面进行热点配置: - -![image-20220328145654180](https://tva1.sinaimg.cn/large/e6c9d24ely1h0plrnnjlqj22fh0u0aec.jpg) - -然后开始访问我们的测试接口,可以看到在携带参数a时,当访问频率超过设定值,就会直接被限流,这里是直接在后台抛出异常: - -![image-20220328145726479](https://tva1.sinaimg.cn/large/e6c9d24ely1h0pls7lm97j21j20d6q4z.jpg) - -![image-20220328145851133](https://tva1.sinaimg.cn/large/e6c9d24ely1h0plto9gujj222e07imyn.jpg) - -而我们使用其他参数或是不带`a`参数,那么就不会出现这种问题了: - -![image-20220328145838378](https://tva1.sinaimg.cn/large/e6c9d24ely1h0pltgil5hj214w07mwf3.jpg) - -除了直接对某个参数精准限流外,我们还可以对参数携带的指定值单独设定阈值,比如我们现在不仅希望对参数`a`限流,而且还希望当参数`a`的值为10时,QPS达到5再进行限流,那么就可以设定例外: - -![image-20220328150138096](https://tva1.sinaimg.cn/large/e6c9d24ely1h0plwl48w0j220a0u0gp0.jpg) - -这样,当请求携带参数`a`,且参数`a`的值为10时,阈值将按照我们指定的特例进行计算。 - -### 服务熔断和降级 - -还记得我们前所说的服务降级吗,也就是说我们需要在整个微服务调用链路出现问题的时候,及时对服务进行降级,以防止问题进一步恶化。 - -![image-20220324141706946](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ky50sw4jj219s07yabg.jpg) - -那么,各位是否有思考过,如果在某一时刻,服务B出现故障(可能就卡在那里了),而这时服务A依然有大量的请求,在调用服务B,那么,由于服务A没办法再短时间内完成处理,新来的请求就会导致线程数不断地增加,这样,CPU的资源很快就会被耗尽。 - -那么要防止这种情况,就只能进行隔离了,这里我们提两种隔离方案: - -1. **线程池隔离** - - 线程池隔离实际上就是对每个服务的远程调用单独开放线程池,比如服务A要调用服务B,那么只基于固定数量的线程池,这样即使在短时间内出现大量请求,由于没有线程可以分配,所以就不会导致资源耗尽了。 - - ![image-20220328121932455](https://tva1.sinaimg.cn/large/e6c9d24ely1h0ph7xi5qbj21fo09y404.jpg) - -2. **信号量隔离** - - 信号量隔离是使用`Semaphore`类实现的(如果不了解,可以观看本系列 并发编程篇 视频教程),思想基本上与上面是相同的,也是限定指定的线程数量能够同时进行服务调用,但是它相对于线程池隔离,开销会更小一些,使用效果同样优秀,也支持超时等。 - - Sentinel也正是采用的这种方案实现隔离的。 - -好了,说回我们的熔断和降级,当下游服务因为某种原因变得不可用或响应过慢时,上游服务为了保证自己整体服务的可用性,不再继续调用目标服务而是快速返回或是执行自己的替代方案,这便是服务降级。 - -![image-20220328124619289](https://tva1.sinaimg.cn/large/e6c9d24ely1h0phzsecsij20ig0i274r.jpg) - -整个过程分为三个状态: - -* 关闭:熔断器不工作,所有请求全部该干嘛干嘛。 -* 打开:熔断器工作,所有请求一律降级处理。 -* 半开:尝试进行一下下正常流程,要是还不行继续保持打开状态,否则关闭。 - -那么我们来看看Sentinel中如何进行熔断和降级操作,打开管理页面,我们可以自由新增熔断规则: - -![image-20220328125115760](https://tva1.sinaimg.cn/large/e6c9d24ely1h0pi4xii37j22mo0t4dku.jpg) - -其中,熔断策略有三种模式: - -1. **慢调用比例:**如果出现那种半天都处理不完的调用,有可能就是服务出现故障,导致卡顿,这个选项是按照最大响应时间(RT)进行判定,如果一次请求的处理时间超过了指定的RT,那么就被判定为`慢调用`,在一个统计时长内,如果请求数目大于最小请求数目,并且被判定为`慢调用`的请求比例已经超过阈值,将触发熔断。经过熔断时长之后,将会进入到半开状态进行试探(这里和Hystrix一致) - - 然后修改一下接口的执行,我们模拟一下慢调用: - - ```java - @RequestMapping("/borrow2/{uid}") - UserBorrowDetail findUserBorrows2(@PathVariable("uid") int uid) throws InterruptedException { - Thread.sleep(1000); - return null; - } - ``` - - 重启,然后我们创建一个新的熔断规则: - - ![image-20220328131105084](https://tva1.sinaimg.cn/large/e6c9d24ely1h0pipk23g6j227a0o0tbt.jpg) - - 可以看到,超时直接触发了熔断,进入到阻止页面: - - ![image-20220328131018951](https://tva1.sinaimg.cn/large/e6c9d24ely1h0pior2olsj21nm088wf8.jpg) - -2. **异常比例:**这个与慢调用比例类似,不过这里判断的是出现异常的次数,与上面一样,我们也来进行一些小测试: - - ```java - @RequestMapping("/borrow2/{uid}") - UserBorrowDetail findUserBorrows2(@PathVariable("uid") int uid) { - throw new RuntimeException(); - } - ``` - - 启动服务器,接着添加我们的熔断规则: - - ![image-20220328132443315](https://tva1.sinaimg.cn/large/e6c9d24ely1h0pj3qutgtj225o0noju9.jpg) - - 现在我们进行访问,会发现后台疯狂报错,然后就熔断了: - - ![image-20220328132815856](https://tva1.sinaimg.cn/large/e6c9d24ely1h0pj7f4fgbj22180cswjm.jpg) - - ![image-20220328132804164](https://tva1.sinaimg.cn/large/e6c9d24ely1h0pj78bdkgj21a606i3z5.jpg) - -3. **异常数:**这个和上面的唯一区别就是,只要达到指定的异常数量,就熔断,这里我们修改一下熔断规则: - - ![image-20220328132927745](https://tva1.sinaimg.cn/large/e6c9d24ely1h0pj8oajy0j221u0nk419.jpg) - - 现在我们再次不断访问此接口,可以发现,效果跟之前其实是差不多的,只是判断的策略稍微不同罢了: - - ![image-20220328132804164](https://tva1.sinaimg.cn/large/e6c9d24ely1h0pj78bdkgj21a606i3z5.jpg) - -那么熔断规则如何设定我们了解了,那么,如何自定义服务降级呢?之前在使用Hystrix的时候,如果出现异常,可以执行我们的替代方案,Sentinel也是可以的。 - -同样的,我们只需要在`@SentinelResource`中配置`blockHandler`参数(那这里跟前面那个方法限流的配置不是一毛一样吗?没错,因为如果添加了`@SentinelResource`注解,那么这里会进行方法级别细粒度的限制,和之前方法级别限流一样,会在降级之后直接抛出异常,如果不添加则返回默认的限流页面,`blockHandler`的目的就是处理这种Sentinel机制上的异常,所以这里其实和之前的限流配置是一个道理,因此下面熔断配置也应该对`value`自定义名称的资源进行配置,才能作用到此方法上): - -```java -@RequestMapping("/borrow2/{uid}") -@SentinelResource(value = "findUserBorrows2", blockHandler = "test") -UserBorrowDetail findUserBorrows2(@PathVariable("uid") int uid) { - throw new RuntimeException(); -} - -UserBorrowDetail test(int uid, BlockException e){ - return new UserBorrowDetail(new User(), Collections.emptyList()); -} -``` - -接着我们对进行熔断配置,注意是对我们添加的`@SentinelResource`中指定名称的`findUserBorrows2`进行配置: - -![image-20220328160248977](https://tva1.sinaimg.cn/large/e6c9d24ely1h0pno8hcvoj22le0nuafl.jpg) - -OK,可以看到熔断之后,服务降级之后的效果: - -![image-20220328160112038](https://tva1.sinaimg.cn/large/e6c9d24ely1h0pnmjycafj21ci06o3z9.jpg) - -最后我们来看一下如何让Feign的也支持Sentinel,前面我们使用Hystrix的时候,就可以直接对Feign的每个接口调用单独进行服务降级,而使用Sentinel,也是可以的,首先我们需要在配置文件中开启支持: - -```yml -feign: - sentinel: - enabled: true -``` - -之后的步骤其实和之前是一模一样的,首先创建实现类: - -```java -@Component -public class UserClientFallback implements UserClient{ - @Override - public User getUserById(int uid) { - User user = new User(); - user.setName("我是替代方案"); - return user; - } -} -``` - -然后直接启动就可以了,中途的时候我们吧用户服务全部下掉,可以看到正常使用替代方案: - -![image-20220328165606119](https://tva1.sinaimg.cn/large/e6c9d24ely1h0pp7oe8yuj228k06iwfy.jpg) - -这样Feign的配置就OK了,那么传统的RestTemplate呢?我们可以使用`@SentinelRestTemplate`注解实现: - -```java - @Bean - @LoadBalanced - @SentinelRestTemplate(blockHandler = "handleException", blockHandlerClass = ExceptionUtil.class, - fallback = "fallback", fallbackClass = ExceptionUtil.class) //这里同样可以设定fallback等参数 - public RestTemplate restTemplate() { - return new RestTemplate(); - } -``` - -这里就不多做赘述了。 - -*** - -![image-20220329112537891](https://tva1.sinaimg.cn/large/e6c9d24egy1h0qla4zlihj21m0064glz.jpg) - -## Seata与分布式事务 - -重难点内容,坑也多得离谱,最好保持跟UP一样的版本,**官方文档:**https://seata.io/zh-cn/docs/overview/what-is-seata.html - -在前面的阶段中,我们学习过事务,还记得我们之前谈到的数据库事务的特性吗? - -* **原子性:**一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。 -* **一致性:**在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发性地完成预定的工作。 -* **隔离性:**数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交(Read uncommitted)、读已提交(read committed)、可重复读(repeatable read)和串行化(Serializable)。 -* **持久性:**事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。 - -那么各位试想一下,在分布式环境下,有可能出现这样一个问题,比如我们下单购物,那么整个流程可能是这样的:先调用库存服务对库存进行减扣 -> 然后订单服务开始下单 -> 最后用户账户服务进行扣款,虽然看似是一个很简单的一个流程,但是如果没有事务的加持,很有可能会由于中途出错,比如整个流程中订单服务出现问题,那么就会导致库存扣了,但是实际上这个订单并没有生成,用户也没有付款。 - -![image-20220329111304542](https://tva1.sinaimg.cn/large/e6c9d24egy1h0qkx2w3olj21jo0763zr.jpg) - -上面这种情况时间就是一种多服务多数据源的分布式事务模型(比较常见),因此,为了解决这种情况,我们就得实现分布式事务,让这整个流程保证原子性。 - -SpringCloud Alibaba为我们提供了用于处理分布式事务的组件Seata。 - -![image-20220329113049408](https://tva1.sinaimg.cn/large/e6c9d24egy1h0qlfjws7cj21da0qkmzw.jpg) - -Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。 - -实际上,就是多了一个中间人来协调所有服务的事务。 - -### 项目环境搭建 - -这里我们对我们之前的图书管理系统进行升级: - -* 每个用户最多只能同时借阅2本不同的书。 -* 图书馆中所有的书都有3本。 -* 用户借书流程:先调用图书服务书籍数量-1 -> 添加借阅记录 -> 调用用户服务用户可借阅数量-1 - -那么首先我们对数据库进行修改,这里为了简便,就直接在用户表中添加一个字段用于存储用户能够借阅的书籍数量: - -![image-20220329135048063](https://tva1.sinaimg.cn/large/e6c9d24egy1h0qph7expaj21io07e0tt.jpg) - -然后修改书籍信息,也是直接添加一个字段用于记录剩余数量: - -![image-20220329135307659](https://tva1.sinaimg.cn/large/e6c9d24egy1h0qpjm6mlaj21i006k3ze.jpg) - -接着我们去编写一下对应的服务吧,首先是用户服务: - -```java -@Mapper -public interface UserMapper { - @Select("select * from DB_USER where uid = #{uid}") - User getUserById(int uid); - - @Select("select book_count from DB_USER where uid = #{uid}") - int getUserBookRemain(int uid); - - @Update("update DB_USER set book_count = #{count} where uid = #{uid}") - int updateBookCount(int uid, int count); -} -``` - -```java -@Service -public class UserServiceImpl implements UserService { - - @Resource - UserMapper mapper; - - @Override - public User getUserById(int uid) { - return mapper.getUserById(uid); - } - - @Override - public int getRemain(int uid) { - return mapper.getUserBookRemain(uid); - } - - @Override - public boolean setRemain(int uid, int count) { - return mapper.updateBookCount(uid, count) > 0; - } -} -``` - -```java -@RestController -public class UserController { - - @Resource - UserService service; - - @RequestMapping("/user/{uid}") - public User findUserById(@PathVariable("uid") int uid){ - return service.getUserById(uid); - } - - @RequestMapping("/user/remain/{uid}") - public int userRemain(@PathVariable("uid") int uid){ - return service.getRemain(uid); - } - - @RequestMapping("/user/borrow/{uid}") - public boolean userBorrow(@PathVariable("uid") int uid){ - int remain = service.getRemain(uid); - return service.setRemain(uid, remain - 1); - } -} -``` - -然后是图书服务,其实跟用户服务差不多: - -```java -@Mapper -public interface BookMapper { - - @Select("select * from DB_BOOK where bid = #{bid}") - Book getBookById(int bid); - - @Select("select count from DB_BOOK where bid = #{bid}") - int getRemain(int bid); - - @Update("update DB_BOOK set count = #{count} where bid = #{bid}") - int setRemain(int bid, int count); -} -``` - -```java -@Service -public class BookServiceImpl implements BookService { - - @Resource - BookMapper mapper; - - @Override - public Book getBookById(int bid) { - return mapper.getBookById(bid); - } - - @Override - public boolean setRemain(int bid, int count) { - return mapper.setRemain(bid, count) > 0; - } - - @Override - public int getRemain(int bid) { - return mapper.getRemain(bid); - } -} -``` - -```java -@RestController -public class BookController { - - @Resource - BookService service; - - @RequestMapping("/book/{bid}") - Book findBookById(@PathVariable("bid") int bid){ - return service.getBookById(bid); - } - - @RequestMapping("/book/remain/{bid}") - public int bookRemain(@PathVariable("bid") int uid){ - return service.getRemain(uid); - } - - @RequestMapping("/book/borrow/{bid}") - public boolean bookBorrow(@PathVariable("bid") int uid){ - int remain = service.getRemain(uid); - return service.setRemain(uid, remain - 1); - } -} -``` - -最后完善我们的借阅服务: - -```java -@FeignClient(value = "userservice") -public interface UserClient { - - @RequestMapping("/user/{uid}") - User getUserById(@PathVariable("uid") int uid); - - @RequestMapping("/user/borrow/{uid}") - boolean userBorrow(@PathVariable("uid") int uid); - - @RequestMapping("/user/remain/{uid}") - int userRemain(@PathVariable("uid") int uid); -} -``` - -```java -@FeignClient("bookservice") -public interface BookClient { - - @RequestMapping("/book/{bid}") - Book getBookById(@PathVariable("bid") int bid); - - @RequestMapping("/book/borrow/{bid}") - boolean bookBorrow(@PathVariable("bid") int bid); - - @RequestMapping("/book/remain/{bid}") - int bookRemain(@PathVariable("bid") int bid); -} -``` - -```java -@RestController -public class BorrowController { - - @Resource - BorrowService service; - - @RequestMapping("/borrow/{uid}") - UserBorrowDetail findUserBorrows(@PathVariable("uid") int uid){ - return service.getUserBorrowDetailByUid(uid); - } - - @RequestMapping("/borrow/take/{uid}/{bid}") - JSONObject borrow(@PathVariable("uid") int uid, - @PathVariable("bid") int bid){ - service.doBorrow(uid, bid); - - JSONObject object = new JSONObject(); - object.put("code", "200"); - object.put("success", false); - object.put("message", "借阅成功!"); - return object; - } -} -``` - -```java -@Service -public class BorrowServiceImpl implements BorrowService{ - - @Resource - BorrowMapper mapper; - - @Resource - UserClient userClient; - - @Resource - BookClient bookClient; - - @Override - public UserBorrowDetail getUserBorrowDetailByUid(int uid) { - List borrow = mapper.getBorrowsByUid(uid); - User user = userClient.getUserById(uid); - List bookList = borrow - .stream() - .map(b -> bookClient.getBookById(b.getBid())) - .collect(Collectors.toList()); - return new UserBorrowDetail(user, bookList); - } - - @Override - public boolean doBorrow(int uid, int bid) { - //1. 判断图书和用户是否都支持借阅 - if(bookClient.bookRemain(bid) < 1) - throw new RuntimeException("图书数量不足"); - if(userClient.userRemain(uid) < 1) - throw new RuntimeException("用户借阅量不足"); - //2. 首先将图书的数量-1 - if(!bookClient.bookBorrow(bid)) - throw new RuntimeException("在借阅图书时出现错误!"); - //3. 添加借阅信息 - if(mapper.getBorrow(uid, bid) != null) - throw new RuntimeException("此书籍已经被此用户借阅了!"); - if(mapper.addBorrow(uid, bid) <= 0) - throw new RuntimeException("在录入借阅信息时出现错误!"); - //4. 用户可借阅-1 - if(!userClient.userBorrow(uid)) - throw new RuntimeException("在借阅时出现错误!"); - //完成 - return true; - } -} -``` - -这样,只要我们的图书借阅过程中任何一步出现问题,都会抛出异常。 - -我们来测试一下: - -![image-20220329151445740](https://tva1.sinaimg.cn/large/e6c9d24egy1h0qrwk3pc6j21fo070aav.jpg) - -再次尝试借阅,后台会直接报错: - -![image-20220329151512871](https://tva1.sinaimg.cn/large/e6c9d24egy1h0qrx0lrghj21oi06umzv.jpg) - -抛出异常,但是我们发现一个问题,借阅信息添加失败了,但是图书的数量依然被-1,也就是说正常情况下,我们是希望中途出现异常之后,之前的操作全部回滚的: - -![image-20220329151615894](https://tva1.sinaimg.cn/large/e6c9d24egy1h0qry46lk7j214o05s3z9.jpg) - -而这里由于是在另一个服务中进行的数据库操作,所以传统的`@Transactional`注解无效,这时就得借助Seata提供分布式事务了。 - -### 分布式事务解决方案 - -要开始实现分布式事务,我们得先从理论上开始下手,我们来了解一下常用的分布式事务解决方案。 - -1. **XA分布式事务协议 - 2PC(两阶段提交实现)** - - 这里的PC实际上指的是Prepare和Commit,也就是说它分为两个阶段,一个是准备一个是提交,整个过程的参与者一共有两个角色,一个是事务的执行者,一个是事务的协调者,实际上整个分布式事务的运作都需要依靠协调者来维持: - - ![image-20220331214050440](https://tva1.sinaimg.cn/large/e6c9d24ely1h0teawzsnnj21hc0duabp.jpg) - - 在准备和提交阶段,会进行: - - * **准备阶段:** - - 一个分布式事务是由协调者来开启的,首先协调者会向所有的事务执行者发送事务内容,等待所有的事务执行者答复。 - - 各个事务执行者开始执行事务操作,但是不进行提交,并将undo和redo信息记录到事务日志中。 - - 如果事务执行者执行事务成功,那么就告诉协调者成功Yes,否则告诉协调者失败No,不能提交事务。 - - * **提交阶段:** - - 当所有的执行者都反馈完成之后,进入第二阶段。 - - 协调者会检查各个执行者的反馈内容,如果所有的执行者都返回成功,那么就告诉所有的执行者可以提交事务了,最后再释放锁资源。 - - 如果有至少一个执行者返回失败或是超时,那么就让所有的执行者都回滚,分布式事务执行失败。 - - 虽然这种方式看起来比较简单,但是存在以下几个问题: - - * 事务协调者是非常核心的角色,一旦出现问题,将导致整个分布式事务不能正常运行。 - * 如果提交阶段发生网络问题,导致某些事务执行者没有收到协调者发来的提交命令,将导致某些执行者提交某些执行者没提交,这样肯定是不行的。 - -2. **XA分布式事务协议 - 3PC(三阶段提交实现)** - - 三阶段提交是在二阶段提交基础上的改进版本,主要是加入了超时机制,同时在协调者和执行者中都引入了超时机制。 - - 三个阶段分别进行: - - * **CanCommit阶段:** - - 协调者向执行者发送CanCommit请求,询问是否可以执行事务提交操作,然后开始等待执行者的响应。 - - 执行者接收到请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态,否则返回No - - * **PreCommit阶段:** - - 协调者根据执行者的反应情况来决定是否可以进入第二阶段事务的PreCommit操作。 - - 如果所有的执行者都返回Yes,则协调者向所有执行者发送PreCommit请求,并进入Prepared阶段,执行者接收到请求后,会执行事务操作,并将undo和redo信息记录到事务日志中,如果成功执行,则返回成功响应。 - - 如果所有的执行者至少有一个返回No,则协调者向所有执行者发送abort请求,所有的执行者在收到请求或是超过一段时间没有收到任何请求时,会直接中断事务。 - - * **DoCommit阶段:** - - 该阶段进行真正的事务提交。 - - 协调者接收到所有执行者发送的成功响应,那么他将从PreCommit状态进入到DoCommit状态,并向所有执行者发送doCommit请求,执行者接收到doCommit请求之后,开始执行事务提交,并在完成事务提交之后释放所有事务资源,并最后向协调者发送确认响应,协调者接收到所有执行者的确认响应之后,完成事务(如果因为网络问题导致执行者没有收到doCommit请求,执行者会在超时之后直接提交事务,虽然执行者只是猜测协调者返回的是doCommit请求,但是因为前面的两个流程都正常执行,所以能够在一定程度上认为本次事务是成功的,因此会直接提交) - - 协调者没有接收至少一个执行者发送的成功响应(也可能是响应超时),那么就会执行中断事务,协调者会向所有执行者发送abort请求,执行者接收到abort请求之后,利用其在PreCommit阶段记录的undo信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源,执行者完成事务回滚之后,向协调者发送确认消息, 协调者接收到参与者反馈的确认消息之后,执行事务的中断。 - - 相比两阶段提交,三阶段提交的优势是显而易见的,当然也有缺点: - - * 3PC在2PC的第一阶段和第二阶段中插入一个准备阶段,保证了在最后提交阶段之前各参与节点的状态是一致的。 - * 一旦参与者无法及时收到来自协调者的信息之后,会默认执行Commit,这样就不会因为协调者单方面的故障导致全局出现问题。 - * 但是我们知道,实际上超时之后的Commit决策本质上就是一个赌注罢了,如果此时协调者发送的是abort请求但是超时未接收,那么就会直接导致数据一致性问题。 - -3. **TCC(补偿事务)** - - 补偿事务TCC就是Try、Confirm、Cancel,它对业务有侵入性,一共分为三个阶段,我们依次来解读一下。 - - * **Try阶段:** - - 比如我们需要在借书时,将书籍的库存`-1`,并且用户的借阅量也`-1`,但是这个操作,除了直接对库存和借阅量进行修改之外,还需要将减去的值,单独存放到冻结表中,但是此时不会创建借阅信息,也就是说只是预先把关键的东西给处理了,预留业务资源出来。 - - * **Confirm阶段:** - - 如果Try执行成功无误,那么就进入到Confirm阶段,接着之前,我们就该创建借阅信息了,只能使用Try阶段预留的业务资源,如果创建成功,那么就对Try阶段冻结的值,进行解冻,整个流程就完成了。当然,如果失败了,那么进入到Cancel阶段。 - - * **Cancel阶段:** - - 不用猜了,那肯定是把冻结的东西还给人家,因为整个借阅操作压根就没成功。就像你付了款买了东西但是网络问题,导致交易失败,钱不可能不还给你吧。 - - 跟XA协议相比,TCC就没有协调者这一角色的参与了,而是自主通过上一阶段的执行情况来确保正常,充分利用了集群的优势,性能也是有很大的提升。但是缺点也很明显,它与业务具有一定的关联性,需要开发者去编写更多的补偿代码,同时并不一定所有的业务流程都适用于这种形式。 - -### Seata机制简介 - -前面我们了解了一些分布式事务的解决方案,那么我们来看一下Seata是如何进行分布式事务的处理的。 - -![image-20220401144916943](https://tva1.sinaimg.cn/large/e6c9d24ely1h0u80yrhjkj21tq0ok77i.jpg) - -官网给出的是这样的一个架构图,那么图中的RM、TM、TC代表着什么意思呢? - -* RM(Resource Manager):用于直接执行本地事务的提交和回滚。 -* TM(Transaction Manager):TM是分布式事务的核心管理者。比如现在我们需要在借阅服务中开启全局事务,来让其自身、图书服务、用户服务都参与进来,也就是说一般全局事务发起者就是TM。 -* TC(Transaction Manager)这个就是我们的Seata服务器,用于全局控制,比如在XA模式下就是一个协调者的角色,而一个分布式事务的启动就是由TM向TC发起请求,TC再来与其他的RM进行协调操作。 - -> TM请求TC开启一个全局事务,TC会生成一个XID作为该全局事务的编号,XID会在微服务的调用链路中传播,保证将多个微服务的子事务关联在一起;RM请求TC将本地事务注册为全局事务的分支事务,通过全局事务的XID进行关联;TM请求TC告诉XID对应的全局事务是进行提交还是回滚;TC驱动RM将XID对应的自己的本地事务进行提交还是回滚; - -Seata支持4种事务模式,官网文档:https://seata.io/zh-cn/docs/overview/what-is-seata.html - -* AT:本质上就是2PC的升级版,在 AT 模式下,用户只需关心自己的 “业务SQL” - - 1. 一阶段,Seata 会拦截“业务 SQL”,首先解析 SQL 语义,找到“业务 SQL”要更新的业务数据,在业务数据被更新前,将其保存成“before image”,然后执行“业务 SQL”更新业务数据,在业务数据更新之后,再将其保存成“after image”,最后生成行锁。以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性。 - 2. 二阶段如果确认提交的话,因为“业务 SQL”在一阶段已经提交至数据库, 所以 Seata 框架只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可,当然如果需要回滚,那么就用“before image”还原业务数据;但在还原前要首先要校验脏写,对比“数据库当前业务数据”和 “after image”,如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要转人工处理。 - -* TCC:和我们上面讲解的思路是一样的。 - -* XA:同上,但是要求数据库本身支持这种模式才可以。 - -* Saga:用于处理长事务,每个执行者需要实现事务的正向操作和补偿操作: - - ![image-20220401150544921](https://tva1.sinaimg.cn/large/e6c9d24ely1h0u8i381g0j22au0p0gnt.jpg) - -那么,以AT模式为例,我们的程序如何才能做到不对业务进行侵入的情况下实现分布式事务呢?实际上,Seata客户端,是通过对数据源进行代理实现的,使用的是DataSourceProxy类,所以在程序这边,我们只需要将对应的代理类注册为Bean即可(0.9版本之后支持自动进行代理,不用我们手动操作) - -接下来,我们就以AT模式为例进行讲解。 - -### 使用file模式部署 - -Seata也是以服务端形式进行部署的,然后每个服务都是客户端,服务端下载地址:https://github.com/seata/seata/releases/download/v1.4.2/seata-server-1.4.2.zip - -把源码也下载一下:https://github.com/seata/seata/archive/refs/heads/develop.zip - -下载完成之后,放入到IDEA项目目录中,添加启动配置,这里端口使用8868: - -![image-20220331150441431](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t2uouvdmj227k0fu429.jpg) - -Seata服务端支持本地部署或是基于注册发现中心部署(比如Nacos、Eureka等),这里我们首先演示一下最简单的本地部署,不需要对Seata的配置文件做任何修改。 - -Seata存在着事务分组机制: - -- 事务分组:seata的资源逻辑,可以按微服务的需要,在应用程序(客户端)对自行定义事务分组,每组取一个名字。 -- 集群:seata-server服务端一个或多个节点组成的集群cluster。 应用程序(客户端)使用时需要指定事务逻辑分组与Seata服务端集群(默认为default)的映射关系。 - -为啥要设计成通过事务分组再直接映射到集群?干嘛不直接指定集群呢?获取事务分组到映射集群的配置。这样设计后,事务分组可以作为资源的逻辑隔离单位,出现某集群故障时可以快速failover,只切换对应分组,可以把故障缩减到服务级别,但前提也是你有足够server集群。 - -接着我们需要将我们的各个服务作为Seate的客户端,只需要导入依赖即可: - -```xml - - com.alibaba.cloud - spring-cloud-starter-alibaba-seata - -``` - -然后添加配置: - -```yaml -seata: - service: - vgroup-mapping: - # 这里需要对事务组做映射,默认的分组名为 应用名称-seata-service-group,将其映射到default集群 - # 这个很关键,一定要配置对,不然会找不到服务 - bookservice-seata-service-group: default - grouplist: - default: localhost:8868 -``` - -这样就可以直接启动了,但是注意现在只是单纯地连接上,并没有开启任何的分布式事务。 - -现在我们接着来配置开启分布式事务,首先在启动类添加注解,此注解会添加一个后置处理器将数据源封装为支持分布式事务的代理数据源(虽然官方表示配置文件中已经默认开启了自动代理,但是UP主实测1.4.2版本下只能打注解的方式才能生效): - -```java -@EnableAutoDataSourceProxy -@SpringBootApplication -public class BookApplication { - public static void main(String[] args) { - SpringApplication.run(BookApplication.class, args); - } -} -``` - -接着我们需要在开启分布式事务的方法上添加`@GlobalTransactional`注解: - -```java -@GlobalTransactional -@Override -public boolean doBorrow(int uid, int bid) { - //这里打印一下XID看看,其他的服务业添加这样一个打印,如果一会都打印的是同一个XID,表示使用的就是同一个事务 - System.out.println(RootContext.getXID()); - if(bookClient.bookRemain(bid) < 1) - throw new RuntimeException("图书数量不足"); - if(userClient.userRemain(uid) < 1) - throw new RuntimeException("用户借阅量不足"); - if(!bookClient.bookBorrow(bid)) - throw new RuntimeException("在借阅图书时出现错误!"); - if(mapper.getBorrow(uid, bid) != null) - throw new RuntimeException("此书籍已经被此用户借阅了!"); - if(mapper.addBorrow(uid, bid) <= 0) - throw new RuntimeException("在录入借阅信息时出现错误!"); - if(!userClient.userBorrow(uid)) - throw new RuntimeException("在借阅时出现错误!"); - return true; -} -``` - -还没结束,我们前面说了,Seata会分析修改数据的sql,同时生成对应的反向回滚SQL,这个回滚记录会存放在undo_log 表中。所以要求每一个Client 都有一个对应的undo_log表(也就是说每个服务连接的数据库都需要创建这样一个表,这里由于我们三个服务都用的同一个数据库,所以说就只用在这个数据库中创建undo_log表即可),表SQL定义如下: - -```sql -CREATE TABLE `undo_log` -( - `id` BIGINT(20) NOT NULL AUTO_INCREMENT, - `branch_id` BIGINT(20) NOT NULL, - `xid` VARCHAR(100) NOT NULL, - `context` VARCHAR(128) NOT NULL, - `rollback_info` LONGBLOB NOT NULL, - `log_status` INT(11) NOT NULL, - `log_created` DATETIME NOT NULL, - `log_modified` DATETIME NOT NULL, - `ext` VARCHAR(100) DEFAULT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`) -) ENGINE = InnoDB - AUTO_INCREMENT = 1 - DEFAULT CHARSET = utf8; -``` - -创建完成之后,我们现在就可以启动三个服务了,我们来测试一下当出现异常的时候是不是会正常回滚: - -![image-20220331153615187](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t3rj1lyqj21kq06gwfc.jpg) - -![image-20220331153823961](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t3tr1a3ej214i05idgj.jpg) - -首先第一次肯定是正常完成借阅操作的,接着我们再次进行请求,肯定会出现异常: - -![image-20220331153655710](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t3s88j4oj21rm0cwmze.jpg) - -![image-20220331153729453](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t3stb2oaj221g0a6jwj.jpg) - -如果能在栈追踪信息中看到seata相关的包,那么说明分布式事务已经开始工作了,通过日志我们可以看到,出现了回滚操作: - -![image-20220331153911759](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t3ul16g8j222403c40z.jpg) - -并且数据库中确实是回滚了扣除操作: - -![image-20220331153852374](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t3u8rckaj214i05idgj.jpg) - -这样,我们就通过Seata简单地实现了分布式事务。 - -### 使用nacos模式部署 - -前面我们实现了本地Seata服务的file模式部署,现在我们来看看如何让其配合Nacos进行部署,利用Nacos的配置管理和服务发现机制,Seata能够更好地工作。 - -我们先单独为Seata配置一个命名空间: - -![image-20220331155823306](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t4ejq5rlj22cu0ca3zx.jpg) - -我们打开`conf`目录中的`registry.conf`配置文件: - -```properties -registry { - # 注册配置 - # 可以看到这里可以选择类型,默认情况下是普通的file类型,也就是本地文件的形式进行注册配置 - # 支持的类型如下,对应的类型在下面都有对应的配置 - # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa - type = "nacos" - - # 采用nacos方式会将seata服务端也注册到nacos中,这样客户端就可以利用服务发现自动找到seata服务 - # 就不需要我们手动指定IP和端口了,不过看似方便,坑倒是不少,后面再说 - nacos { - # 应用名称,这里默认就行 - application = "seata-server" - # Nacos服务器地址 - serverAddr = "localhost:8848" - # 这里使用的是SEATA_GROUP组,一会注册到Nacos中就是这个组 - group = "SEATA_GROUP" - # 这里就使用我们上面单独为seata配置的命名空间,注意填的是ID - namespace = "89fc2145-4676-48b8-9edd-29e867879bcb" - # 集群名称,这里还是使用default - cluster = "default" - # Nacos的用户名和密码 - username = "nacos" - password = "nacos" - } - #... -``` - -注册信息配置完成之后,接着我们需要将配置文件也放到Nacos中,让Nacos管理配置,这样我们就可以对配置进行热更新了,一旦环境需要变化,只需要直接在Nacos中修改即可。 - -```properties -config { - # 这里我们也使用nacos - # file、nacos 、apollo、zk、consul、etcd3 - type = "nacos" - - nacos { - # 跟上面一样的配法 - serverAddr = "127.0.0.1:8848" - namespace = "89fc2145-4676-48b8-9edd-29e867879bcb" - group = "SEATA_GROUP" - username = "nacos" - password = "nacos" - # 这个不用改,默认就行 - dataId = "seataServer.properties" - } -``` - -接着,我们需要将配置导入到Nacos中,我们打开一开始下载的源码`script/config-center/nacos`目录,这是官方提供的上传脚本,我们直接运行即可(windows下没对应的bat就很蛋疼,可以使用git命令行来运行一下),这里我们使用这个可交互的版本: - -![image-20220331160748379](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t4ocvxg8j218k0fi0uu.jpg) - -按照提示输入就可以了,不输入就使用的默认值,不知道为啥最新版本有四个因为参数过长还导入失败了,就离谱,不过不影响。 - -导入成功之后,可以在对应的命名空间下看到对应的配置(为啥非要一个一个配置项单独搞,就不能写一起吗): - -![image-20220331160918380](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t4px1ddhj21ue0u0jwp.jpg) - -注意,还没完,我们还需要将对应的事务组映射配置也添加上,DataId格式为`service.vgroupMapping.事务组名称`,比如我们就使用默认的名称,值全部依然使用default即可: - -![image-20220331161119169](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t4s0ehpxj22cc0detbd.jpg) - -现在我们就完成了服务端的Nacos配置,接着我们需要对客户端也进行Nacos配置: - -```yaml -seata: - # 注册 - registry: - # 使用Nacos - type: nacos - nacos: - # 使用Seata的命名空间,这样才能正确找到Seata服务,由于组使用的是SEATA_GROUP,配置默认值就是,就不用配了 - namespace: 89fc2145-4676-48b8-9edd-29e867879bcb - username: nacos - password: nacos - # 配置 - config: - type: nacos - nacos: - namespace: 89fc2145-4676-48b8-9edd-29e867879bcb - username: nacos - password: nacos -``` - -现在我们就可以启动这三个服务了,可以在Nacos中看到Seata以及三个服务都正常注册了: - -![image-20220331162215864](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t53eig40j22cq0asac4.jpg) - -![image-20220331162241748](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t53udw2dj22cm05mt9k.jpg) - -接着我们就可以访问一下服务试试看了: - -![image-20220331162601073](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t57awz7nj21kg06qt9j.jpg) - -可以看到效果和上面是一样的,不过现在我们的注册和配置都继承在Nacos中进行了。 - -我们还可以配置一下事务会话信息的存储方式,默认是file类型,那么就会在运行目录下创建`file_store`目录,我们可以将其搬到数据库中存储,只需要修改一下配置即可: - -![image-20220331162840368](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t5a2q6itj22ca0aywgc.jpg) - -将`store.session.mode`和`store.mode`的值修改为`db` - -接着我们对数据库信息进行一下配置: - -* 数据库驱动 -* 数据库URL -* 数据库用户名密码 - -其他的默认即可: - -![image-20220331163100436](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t5chy3tmj226y0u00yl.jpg) - -接着我们需要将对应的数据库进行创建,创建seata数据库,然后直接CV以下语句: - -```sql --- -------------------------------- The script used when storeMode is 'db' -------------------------------- --- the table to store GlobalSession data -CREATE TABLE IF NOT EXISTS `global_table` -( - `xid` VARCHAR(128) NOT NULL, - `transaction_id` BIGINT, - `status` TINYINT NOT NULL, - `application_id` VARCHAR(32), - `transaction_service_group` VARCHAR(32), - `transaction_name` VARCHAR(128), - `timeout` INT, - `begin_time` BIGINT, - `application_data` VARCHAR(2000), - `gmt_create` DATETIME, - `gmt_modified` DATETIME, - PRIMARY KEY (`xid`), - KEY `idx_status_gmt_modified` (`status` , `gmt_modified`), - KEY `idx_transaction_id` (`transaction_id`) -) ENGINE = InnoDB - DEFAULT CHARSET = utf8mb4; - --- the table to store BranchSession data -CREATE TABLE IF NOT EXISTS `branch_table` -( - `branch_id` BIGINT NOT NULL, - `xid` VARCHAR(128) NOT NULL, - `transaction_id` BIGINT, - `resource_group_id` VARCHAR(32), - `resource_id` VARCHAR(256), - `branch_type` VARCHAR(8), - `status` TINYINT, - `client_id` VARCHAR(64), - `application_data` VARCHAR(2000), - `gmt_create` DATETIME(6), - `gmt_modified` DATETIME(6), - PRIMARY KEY (`branch_id`), - KEY `idx_xid` (`xid`) -) ENGINE = InnoDB - DEFAULT CHARSET = utf8mb4; - --- the table to store lock data -CREATE TABLE IF NOT EXISTS `lock_table` -( - `row_key` VARCHAR(128) NOT NULL, - `xid` VARCHAR(128), - `transaction_id` BIGINT, - `branch_id` BIGINT NOT NULL, - `resource_id` VARCHAR(256), - `table_name` VARCHAR(32), - `pk` VARCHAR(36), - `status` TINYINT NOT NULL DEFAULT '0' COMMENT '0:locked ,1:rollbacking', - `gmt_create` DATETIME, - `gmt_modified` DATETIME, - PRIMARY KEY (`row_key`), - KEY `idx_status` (`status`), - KEY `idx_branch_id` (`branch_id`) -) ENGINE = InnoDB - DEFAULT CHARSET = utf8mb4; - -CREATE TABLE IF NOT EXISTS `distributed_lock` -( - `lock_key` CHAR(20) NOT NULL, - `lock_value` VARCHAR(20) NOT NULL, - `expire` BIGINT, - primary key (`lock_key`) -) ENGINE = InnoDB - DEFAULT CHARSET = utf8mb4; - -INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('HandleAllSession', ' ', 0); -``` - -![image-20220331163823920](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t5k6ifdbj21ga07yjro.jpg) - -完成之后,重启Seata服务端即可: - -![image-20220331164449635](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t5qvps8pj21vi04kgo7.jpg) - -看到了数据源初始化成功,现在已经在使用数据库进行会话存储了。 - -如果Seata服务端出现报错,可能是我们自定义事务组的名称太长了: - -![image-20220331165020756](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t5wm6n02j22ee0bkagp.jpg) - -将`globle_table`表的字段`transaction_server_group`长度适当增加一下即可: - -![image-20220331165103414](https://tva1.sinaimg.cn/large/e6c9d24ely1h0t5xcvn5kj214s0f4760.jpg) - -到此,关于基于nacos模式下的Seata部署,就完成了。 - -虽然我们这里实现了分布式事务,但是还是给各位同学提出一个问题(可以把自己所认为的结果打在弹幕上),就我们目前这样的程序设计,在高并发下,真的安全吗?比如同一时间100个同学抢同一个书,但是我们知道同一个书就只有3本,如果这时真的同时来了100个请求要借书,会正常地只借出3本书吗?如果不正常,该如何处理? diff --git a/青空笔记/数据结构笔记/数据结构与算法(一).md b/青空笔记/数据结构笔记/数据结构与算法(一).md deleted file mode 100644 index 3858562..0000000 --- a/青空笔记/数据结构笔记/数据结构与算法(一).md +++ /dev/null @@ -1,1978 +0,0 @@ -![image-20220709170114962](https://s2.loli.net/2022/07/09/otEZIiDc3WX9h4f.png) - -# 线性结构篇 - -**注意:**开始本篇学习之前,请确保你完成了 **C语言程序设计** 篇视频教程,否则无法进行学习。 - -我们本系列课程分为基础知识和算法实战两部分,其中算法实战在LeetCode上进行:https://leetcode.cn/,各位可以提前在平台上注册好相关账号。 - -学习完数据结构,各位小伙伴可以尝试参加算法相关的学科竞赛,如ICPC-ACM、蓝桥杯等,算法类的比赛含金量相比项目类比赛更高,也更有价值,相应的,算法类竞赛难道会更大一些,尤其是ICPC-ACM大学生程序设计竞赛,一般都是各个高校内顶尖级队伍进行参赛,甚至还有中学队伍(这类学生预定清华、北大),因为算法更加考验个人的思维能力和天赋水平,相比其他计算机基础课程,数据结构和算法是难度最高的,也是各大高校考研的重点内容。 - -不过虽然很难,并且考验个人天赋,但是大部分人通过努力学习是完全能够掌握基础部分的,在应对80%的题目时,是完全有机会解决的,所以,不要怀疑自己,说不定你就是下一个大佬。 - -这里也说一下面试推荐书籍,内含多种常用算法以及解题分析,值得一看: - -![image-20220709170248015](https://s2.loli.net/2022/07/09/TurKEpDHmvlgJhZ.png) - -本篇内容虽然继续以C语言为基础进行讲解,但是将不再涉及到C语言的语言层面相关内容,更多的是数据结构和算法的思想,实际上用任意一种语言都可以实现。 - -*** - -## 什么是数据结构与算法 - -回顾我们之前的C语言程序设计阶段,我们已经接触过基本数据类型,并且能够使用结构体对数据进行组织,我们可以很轻松地使用一个结构体来存放一个学生的完整数据,在数据结构学习阶段,我们还会进一步地研究。 - -### 数据结构 - -那么,我们来看看,什么是数据结构呢? - -> 数据结构(data structure)是带有结构特性的数据元素的集合,它研究的是数据的[逻辑结构](https://baike.baidu.com/item/逻辑结构/9663235)和数据的物理结构以及它们之间的相互关系。 - -比如现在我们需要保存100个学生的数据,那么你首先想到的肯定是使用数组吧!没错,没有什么比数组更适合存放这100个学生的数据了,但是如果我们现在有了新的需求呢?我们不仅仅是存放这些数据,我们还希望能够将这些数据按顺序存放,支持在某个位置插入一条数据、删除一条数据、修改一条数据等,这时候,数组就显得有些乏力了。 - -![image-20220710103307583](https://s2.loli.net/2022/07/10/9RwL7pxgyfoB3WT.png) - -我们需要一种更好的数据表示和组织方式,才能做到类似于增删改查这样的操作,而完成这些操作所用到的方法,我们称其为“算法”,所以数据结构和算法,一般是放在一起进行讲解的。 - -### 算法 - -比如现在我们希望你求出1-100所有数字的和,请通过程序来实现: - -```c -int main() { - int sum = 0; - for (int i = 1; i <= 100; ++i) sum += i; - printf("%d", sum); -} -``` - -我们很容易就能编写出这样的程序,实际上只需要一个for循环就能搞定了,而这就是我们设计的算法。 - -![image-20220709223103628](https://s2.loli.net/2022/07/09/srPn4baDXWZ9qcJ.png) - -在之前的C语言程序设计阶段,我们其实已经学习了许多算法,包括排序算法、动态规划等。 - -当然,解决问题的算法并不是只有一种,实际上我们上面的方式并不是最优的算法,如果想要求得某一段整数的和,其实使用**高斯求和公式**能够瞬间得到结果: -$$ -\sum=\frac{(首项+末项)\times项数}{2} -$$ -所以,我们完全没必要循环那么多次进行累加计算,而是直接使用数学公式: - -```c -int main() { - printf("%d", (1 + 100) * 100 / 2); -} -``` - -所以,算法的尽头还得是数学啊。 - -可见,不同的算法,执行的效率也是有很大差别的,这里我们就要提到算法的复杂度了。衡量一个算法的复杂程度需要用到以下两个指标: - -* 时间复杂度`T(n)`:算法程序在执行时消耗的时间长度,一般与输入数据的规模n有关。 -* 空间复杂度`S(n)`:算法程序在执行时占用的存储单元长度,同样与数据的输入规模n有关,大部分情况下,我们都是采取空间换时间的算法。 - -比如我们上面的两种算法,第一种需要执行n次循环,每轮循环进行一次累加操作,而第二种只需要进行一次计算即可。实际中我们计算时间复杂度时,其实并不一定要计算精确的执行次数,而只需要大概执行次数,那么这里我们使用`O`渐进表示法。 - -* **大O符号(Big O notation)**:是用于描述函数渐进行为的数学符号。 - -而这里的循环次数,实际上就是我们需要知道的大致执行次数,所以第一种算法的时间复杂度为:`O(n)`,其中n就是项数,因为它需要执行n次计算才能得到最后的结果。而第二种算法的时间复杂度为:`O(1)`,因为它只需要执行一次计算(更准确的说它的执行次数是一个常数,跟项数n毫无关系),显然,当n变得非常大时,第二种方法的计算速度远超第一种。 - -再比如我们之前使用的冒泡排序算法,需要进行两轮循环,而循环的次数在经过优化之后为`(n - 1)(n - 1)/2`,得到的结果中包含了一个`n`的平方,此时这种算法的时间复杂度就来到`O(n^2)`了。 - -在不同的空间复杂度下,可能n小的时候还没什么感觉,但是当n变得非常大时,差距就不是一点半点了,我们来看看常用函数的增长曲线: - -![image-20220709230756755](https://s2.loli.net/2022/07/09/Tr6jI5uPzy2NeDd.png) - -所以我们在设计算法的时候,一定要考虑到时间和空间复杂度的问题,这里列出常用函数的增长表: - -| 函数 | 类型 | 解释 | -| :--------------------: | :--------: | :----------------------------------------------------------: | -| $\Omicron(1)$ | 常数阶 | 如果算法能够优化到这个程度,那么基本上算是最快的算法了。 | -| $\Omicron(\log_{2}n)$ | 对数阶 | 仅次于常数阶的速度,我们后面会介绍的二分搜索算法,就能够到达这个级别。 | -| $\Omicron(n)$ | 线性阶 | 我们后面介绍的线性表插入、删除数据,包括动态规划类算法能够达到线性阶。 | -| $\Omicron(n\log_{2}n)$ | 线性对数阶 | 相当于在对数阶算法外层套了一层线性阶循环。 | -| $\Omicron(n^2)$ | 平方阶 | 我们前面学习的冒泡排序,需要进行两重循环,实际上就是平方阶。 | -| $\Omicron(n^3)$ | 立方阶 | 从立方阶开始,时间复杂度就开始变得有点大了。 | -| $\Omicron(2^n)$ | 指数阶 | 我们前面介绍的斐波那契数列递归算法,就是一个指数阶的算法,因为它包含大量的重复计算。 | -| $\Omicron(n!)$ | 阶乘 | 这个增长速度比指数阶还恐怖,但是一般很少有算法能达到这个等级。 | - -我们在编写算法时,一定要注意算法的时间复杂度,当时间复杂度太大时,可能计算机就很难在短时间内计算出结果了。 - -### 案例:二分搜索算法 - -现在有一个从小到大排序的数组,给你一个目标值`target`,现在请你找到这个值在数组中的对应下标,如果没有,请返回`-1`: - -```c -int search(int* nums, int numsSize, int target){ - //请实现查找算法 -} - -int main() { - int arr[] = {1, 3, 4, 6, 7,8, 10, 11, 13, 15}, target = 3; - printf("%d", search(arr, 10, target)); -} -``` - -此时,最简单的方法就是将数组中的元素一个一个进行遍历,总有一个是,如果遍历完后一个都没有,那么就结束: - -```c -int search(int* nums, int numsSize, int target){ - for (int i = 0; i < len; ++i) { - if(nums[i] == target) return i; //循环n次,直到找到为止 - } - return -1; -} -``` - -虽然这样的算法简单粗暴,但是并不是最好的,我们需要遍历n次才能得到结果,时间复杂度为$\Omicron(n)$,我们可以尝试将其优化到更低的时间复杂度。这里我们利用它有序的特性,实际上当我们查找到大于目标`target`的数时,就没必要继续寻找了: - -```c -int search(int* nums, int numsSize, int target){ - for (int i = 0; i < len; ++i) { - if(nums[i] == target) return i; - if(nums[i] > target) break; - } - return -1; -} -``` - -这样循环进行的次数也许就会减小了,但是如果我们要寻找的目标`target`刚好是最后几个元素呢?这时时间复杂度又来到到了$\Omicron(n)$,那么有没有更好的办法呢?我们依然可以继续利用数组有序的特性,既然是有序的,那么我们不妨随机在数组中找一个数,如果这个数大于目标,那么就不再考虑右边的部分,如果小于目标,那么就考虑左边的部分,然后继续在另一部分中再次随机找一个数,这样每次都能将范围缩小,直到找到为止(其思想就比较类似于**牛顿迭代法**,再次强调数学的重要性) - -![image-20220710095856681](https://s2.loli.net/2022/07/10/CczrjdlmBgPSRIb.png) - -而二分思想就是将一个有序数组不断进行平分,直到找到为止,这样我们每次寻找的范围会不断除以2,所以查找的时间复杂度就降到了$\Omicron(\log_{2}n)$,相比一个一个比较,效率就高了不少: - -![image-20220710101328777](https://s2.loli.net/2022/07/10/FSDcHgG3sOo789z.png) - -好了,那么现在我们就可以利用这种思想,编写出二分搜索算法了,因为每一轮都在进行同样的搜索操作,只是范围不一样,所以这里直接采用递归分治算法: - -```c -int binarySearch(int * nums, int target, int left, int right){ //left代表左边界,right代表右边界 - if(left > right) return -1; //如果左边大于右边,那么肯定就找完了,所以直接返回 - int mid = (left + right) / 2; //这里计算出中间位置 - if(nums[mid] == target) return mid; //直接比较,如果相等就返回下标 - if(nums[mid] > target) //这里就是大于或小于的情况了,这里mid+1和mid-1很多人不理解,实际上就是在下一次寻找中不算上当前的mid,因为这里已经比较过了,所以说左边就-1,右边就+1 - return binarySearch(nums, target, left, mid - 1); //如果大于,那么说明肯定不在右边,直接去左边找 - else - return binarySearch(nums, target, mid + 1, right); //如果小于,那么说明肯定不在左边,直接去右边找 -} - -int search(int* nums, int numsSize, int target){ - return binarySearch(nums, target, 0, numsSize - 1); -} -``` - -当然也可以使用`while`循环来实现二分搜索,如果需要验证自己的代码是否有问题,可以直接在力扣上提交代码:https://leetcode.cn/problems/binary-search/ - -*** - -## 线性表 - -那么作为数据结构的开篇,我们就从最简单的线性表开始介绍。 - -还记得我们开篇提了一个问题吗? - -> 我们还希望能够将这些数据按顺序存放,支持在某个位置插入一条数据、删除一条数据、修改一条数据等,这时候,数组就显得有些乏力了。 - -数组无法做到这么高级的功能,那么我们就需要定义一种更加高级的数据结构来做到,我们可以使用线性表(Linear List) - -> 线性表是由同一类型的数据元素构成的有序序列的线性结构。线性表中元素的个数就是线性表的长度,表的起始位置称为表头,表的结束位置称为表尾,当一个线性表中没有元素时,称为空表。 - -线性表一般需要包含以下功能: - -* **初始化线性表:**将一个线性表进行初始化,得到一个全新的线性表。 -* **获取指定位置上的元素:**直接获取线性表指定位置`i`上的元素。 -* **获取元素的位置:**获取某个元素在线性表上的位置`i`。 -* **插入元素:**在指定位置`i`上插入一个元素。 -* **删除元素:**删除指定位置`i`上的一个元素。 -* **获取长度:**返回线性表的长度。 - -也就是说,现在我们需要设计的是一种功能完善的表结构,它不像是数组那么低级,而是真正意义上的表: - -![image-20220723112639416](https://s2.loli.net/2022/07/23/Ve6dlqROzhumD5o.png) - -简单来说它就是列表,比如我们的菜单,我们在点菜时就需要往菜单列表中添加菜品或是删除菜品,这时列表就很有用了,因为数组长度固定、操作简单,而我们添加菜品、删除菜品这些操作又要求长度动态变化、操作多样。 - -那么,如此高级的数据结构,我们该如何去实现呢?实现线性表的结构一般有两种,一种是顺序存储实现,还有一种是链式存储实现,我们先来看第一种,也是最简单的的一种。 - -### 顺序表 - -前面我们说到,既然数组无法实现这样的高级表结构,那么我就基于数组,对其进行强化,也就是说,我们存放数据还是使用数组,但是我们可以为其编写一些额外的操作来强化为线性表,像这样底层依然采用顺序存储实现的线性表,我们称为顺序表。 - -![image-20220724150015044](https://s2.loli.net/2022/07/24/elBvx4Zo1AJ2WqT.png) - -这里我们可以先定义一个新的结构体类型,将一些需要用到的数据保存在一起,这里我们以`int`类型的线性表为例: - -```c -typedef int E; //这里我们的元素类型就用int为例吧,先起个别名 - -struct List { - E array[10]; //实现顺序表的底层数组 - int capacity; //表示底层数组的容量 -}; -``` - -为了一会使用方便,我们可以给其起一个别名: - -```c -typedef struct List * ArrayList; //因为是数组实现,所以就叫ArrayList,这里直接将List的指针起别名 -``` - -然后我们就可以开始编写第一个初始化操作了: - -```c -void initList(ArrayList list){ - list->capacity = 10; //直接将数组的容量设定为10即可 -} -``` - -但是我们发现一个问题,这样的话我们的顺序表长度不就是固定为10的了吗?而前面我们线性表要求的是长度是动态增长的,那么这个时候怎么办呢?我们可以直接使用一个指针来指向底层数组的内存区域,当装不下的时候,我们可以创建一个新的更大的内存空间来存放数据,这样就可以实现扩容了,所以我们来修改一下: - -```c -struct List { - E * array; //指向顺序表的底层数组 - int capacity; //数组的容量 -}; -``` - -接着我们修改一下初始化函数: - -```c -void initList(ArrayList list){ //这里就默认所有的顺序表初始大小都为10吧,随意 - list->array = malloc(sizeof(E) * 10); //使用malloc函数申请10个int大小的内存空间,作为底层数组使用 - list->capacity = 10; //容量同样设定为10 -} -``` - -但是还没完,因为我们的表里面,默认情况下是没有任何元素的,我们还需要一个变量来表示当前表中的元素数量: - -```c -struct List { - E * array; //指向顺序表的底层数组 - int capacity; //数组的容量 - int size; //表中的元素数量 -}; - -typedef struct List * ArrayList; - -void initList(ArrayList list){ //这里就默认所有的顺序表初始大小都为10吧,随意 - list->array = malloc(sizeof(int) * 10); //使用malloc函数申请10个int大小的内存空间,作为底层数组使用 - list->capacity = 10; //容量同样设定为10 - list->size = 0; //元素数量默认为0 -} -``` - -还有一种情况我们需要考虑,也就是说如果申请内存空间失败,那么需要返回一个结果告诉调用者: - -```c -_Bool initList(ArrayList list){ - list->array = malloc(sizeof(int) * 10); - if(list->array == NULL) return 0; //需要判断如果申请的结果为NULL的话表示内存空间申请失败 - list->capacity = 10; - list->size = 0; - return 1; //正常情况下返回true也就是1 -} -``` - -这样,一个比较简单的顺序表就定义好,我们可以通过`initList`函数对其进行初始化: - -```c -int main() { - struct List list; //创建新的结构体变量 - if(initList(&list)){ //对其进行初始化,如果失败就直接结束 - ... - } else{ - printf("顺序表初始化失败,无法启动程序!"); - } -} -``` - -接着我们来编写一下插入和删除操作,对新手来说也是比较难以理解的操作: - -![image-20220723121423682](https://s2.loli.net/2022/07/23/DdlNcI8rykQsZif.png) - -我们先设计好对应的函数: - -```c -void insertList(ArrayList list, E element, int index){ - //list就是待操作的表,element就是需要插入的元素,index就是插入的位置(注意顺序表的index是按位序计算的,从1开始,一般都是第index个元素) -} -``` - -我们按照上面的思路来编写一下代码: - -```c -void insertList(ArrayList list, E element, int index){ - for (int i = list->size; i > index - 1; i--) //先使用for循环将待插入位置后续的元素全部丢到后一位 - list->array[i] = list->array[i - 1]; - list->array[index - 1] = element; //挪完之后,位置就腾出来了,直接设定即可 - list->size++; //别忘了插入之后相当于多了一个元素,记得size + 1 -} -``` - -现在我们可以来测试一下了: - -```c -void printList(ArrayList list){ //编写一个函数用于打印表当前的数据 - for (int i = 0; i < list->size; ++i) //表里面每个元素都拿出来打印一次 - printf("%d ", list->array[i]); - printf("\n"); -} -``` - -```c -int main() { - struct List list; - if(initList(&list)){ - insertList(&list, 666, 1); //每次插入操作后都打印一下表,看看当前的情况 - printList(&list); - insertList(&list, 777, 1); - printList(&list); - insertList(&list, 888, 2); - printList(&list); - } else{ - printf("顺序表初始化失败,无法启动程序!"); - } -} -``` - -运行结果如下: - -![image-20220723153237528](https://s2.loli.net/2022/07/23/nbSVp2yMqKlJQI6.png) - -虽然这样看起来没什么问题了,但是如果我们在非法的位置插入元素会出现问题: - -```c -insertList(&list, 666, -1); //第一个位置就是0,怎么可能插入到-1这个位置呢,这样肯定是不正确的,所以我们需要进行判断 -printList(&list); -``` - -我们需要检查一下插入的位置是否合法: - -![image-20220723153933279](https://s2.loli.net/2022/07/23/H67F1crBhqQiXxg.png) - -转换成位序,也就是[1, size + 1]这个闭区间,所以我们在一开始的时候进行判断: - -```c -_Bool insertList(ArrayList list, E element, int index){ - if(index < 1 || index > list->size + 1) return 0; //如果在非法位置插入,返回0表示插入操作执行失败 - for (int i = list->size; i > index - 1; i--) - list->array[i] = list->array[i - 1]; - list->array[index - 1] = element; - list->size++; - return 1; //正常情况返回1 -} -``` - -我们可以再来测试一下: - -```c -if(insertList(&list, 666, -1)){ - printList(&list); -} else{ - printf("插入失败!"); -} -``` - -![image-20220723154249242](https://s2.loli.net/2022/07/23/7Q4IxSd2RDKmzBZ.png) - -不过我们还是没有考虑到一个情况,那么就是如果我们的表已经装满了,也就是说size已经达到申请的内存空间最大的大小了,那么此时我们就需要考虑进行扩容了,否则就没办法插入新的元素了: - -```c -_Bool insertList(ArrayList list, E element, int index){ - if(index < 1 || index > list->size + 1) return 0; - if(list->size == list->capacity) { //如果size已经到达最大的容量了,肯定是插不进了,那么此时就需要扩容了 - int newCapacity = list->capacity + (list->capacity >> 1); //我们先计算一下新的容量大小,这里我取1.5倍原长度,当然你们也可以想扩多少扩多少 - E * newArray = realloc(list->array, sizeof(E) * newCapacity); //这里我们使用新的函数realloc重新申请更大的内存空间 - if(newArray == NULL) return 0; //如果申请失败,那么就确实没办法插入了,只能返回0表示插入失败了 - list->array = newArray; - list->capacity = newCapacity; - } - for (int i = list->size; i > index - 1; i--) - list->array[i] = list->array[i - 1]; - list->array[index - 1] = element; - list->size++; - return 1; -} -``` - -> realloc函数可以做到控制动态内存开辟的大小,重新申请的内存空间大小就是我们指定的新的大小,并且原有的数据也会放到新申请的空间中,所以非常方便。当然如果因为内存不足之类的原因导致内存空间申请失败,那么会返回NULL,所以别忘了进行判断。 - -这样,我们的插入操作就编写完善了,我们可以来测试一下: - -```c -int main() { - struct List list; - if(initList(&list)){ - for (int i = 0; i < 30; ++i) - insertList(&list, i, i); - printList(&list); - } else{ - printf("顺序表初始化失败,无法启动程序!"); - } -} -``` - -成功得到结果: - -![image-20220723160222988](https://s2.loli.net/2022/07/23/IqvG1xsUQOo5KwC.png) - -这样,我们就完成了顺序表的插入操作,接着我们来编写一下删除操作,其实删除操作也比较类似,也需要对元素进行批量移动,但是我们不需要考虑扩容问题,我们先设计好函数: - -```c -void deleteList(ArrayList list, int index){ - //list就是待操作的表,index是要删除的元素位序 -} -``` - -按照我们上面插入的思路,我们反过来想一想然后实现删除呢?首先是删除的范围: - -![image-20220723160901921](https://s2.loli.net/2022/07/23/uHBjUfKpd9ygScW.png) - -换算成位序就是[1, size]这个闭区间内容,所以我们先来限定一下合法范围: - -```c -_Bool deleteList(ArrayList list, int index){ - if(index < 1 || index > list->size) return 0; - - return 1; //正常情况返回1 -} -``` - -接着就是删除元素之后,我们还需要做什么呢?我们应该将删除的这个元素后面的全部元素前移一位: - -![image-20220723161412178](https://s2.loli.net/2022/07/23/dgGCcL7q9Pf41tF.png) - -我们按照这个思路,来编写一下删除操作: - -```c -_Bool deleteList(ArrayList list, int index){ - if(index < 1 || index > list->size) return 0; - for (int i = index - 1; i < list->size - 1; ++i) - list->array[i] = list->array[i + 1]; //实际上只需要依次把后面的元素覆盖到前一个即可 - list->size--; //最后别忘了size - 1 - return 1; -} -``` - -删除相比插入要简单一些,我们来测试一下吧: - -```c -for (int i = 0; i < 10; ++i) //先插10个 - insertList(&list, i, i); -deleteList(&list, 5); //这里删除5号元素 -printList(&list); -``` - -成功得到结果: - -![image-20220723161835205](https://s2.loli.net/2022/07/23/q2UrtVlh1RJWKQd.png) - -OK,那么插入和删除操作我们就成功完成了,还有一些比较简单的功能,我们这里也来依次实现一下,首先是获取长度: - -```c -int sizeList(ArrayList list){ - return list->size; //直接返回size就完事 -} -``` - -接着是按位置获取元素和查找指定元素的位置: - -```c -E * getList(ArrayList list, int index){ - if(index < 1 || index > list->size) return NULL; //如果超出范围就返回NULL - return &list->array[index - 1]; -} -``` - -```c -int findList(ArrayList list, E element){ - for (int i = 0; i < list->size; ++i) { //一直遍历,如果找到那就返回位序 - if(list->array[i] == element) return i + 1; - } - return -1; //如果遍历完了都没找到,那么就返回-1 -} -``` - -这样,我们的线性表就实现完成了,完整代码如下: - -```c -#include -#include - -typedef int E; - -struct List { - E * array; - int capacity; - int size; -}; - -typedef struct List * ArrayList; - -_Bool initList(ArrayList list){ - list->array = malloc(sizeof(E) * 10); - if(list->array == NULL) return 0; - list->capacity = 10; - list->size = 0; - return 1; -} - -_Bool insertList(ArrayList list, E element, int index){ - if(index < 1 || index > list->size + 1) return 0; - - if(list->size == list->capacity) { - int newCapacity = list->capacity + (list->capacity >> 1); - E * newArray = realloc(list->array, newCapacity * sizeof(E)); - if(newArray == NULL) return 0; - list->array = newArray; - list->capacity = newCapacity; - } - - for (int i = list->size; i > index - 1; --i) - list->array[i] = list->array[i - 1]; - list->array[index - 1] = element; - list->size++; - return 1; -} - -_Bool deleteList(ArrayList list, int index){ - if(index < 1 || index > list->size) return 0; - for (int i = index - 1; i < list->size - 1; ++i) - list->array[i] = list->array[i + 1]; - list->size--; - return 1; -} - -int sizeList(ArrayList list){ - return list->size; -} - -E * getList(ArrayList list, int index){ - if(index < 1 || index > list->size) return NULL; - return &list->array[index - 1]; -} - -int findList(ArrayList list, E element){ - for (int i = 0; i < list->size; ++i) { - if(list->array[i] == element) return i + 1; - } - return -1; -} -``` - -**问题:**请问顺序实现的线性表,插入、删除、获取元素操作的时间复杂度为? - -* **插入:**因为要将后续所有元素都向后移动,所以平均时间复杂度为$O(n)$ -* **删除:**同上,因为要将所有元素向前移动,所以平均时间复杂度为$O(n)$ -* **获取元素:**因为可以利用数组特性直接通过下标访问到对应元素,所以时间复杂度为$O(1)$ - -**顺序表习题:** - -1. 在一个长度为`n`的顺序表中,向第`i`个元素前插入一个新的元素时,需要向后移动多少个元素? - - A. `n - i` B. `n - i + 1` C. `n - i - 1` D. `i` - - *注意这里要求的是向第`i`个元素前插入(第`i`个表示的是位序,不是下标,不要搞混了,第1个元素下标就为0),这里我们假设`n`为3,`i`为2,那么也就是说要在下标为1的这个位置上插入元素,那么就需要移动后面的2个元素,所以答案是B* - -2. 顺序表是一种( )的存储结构? - - A. 随机存取 B. 顺序存取 C. 索引存取 D. 散列存取 - - *首先顺序表底层是基于数组实现的,那么它肯定是支持随机访问的,因为我们可以直接使用下标想访问哪一个就访问哪一个,所以选择A,不要看到名字叫做顺序表就选择顺序存取,因为它并不需要按照顺序来进行存取,链表才是。这里也没有建立索引去访问元素,也更不可能是散列存取了,散列存取我们会在后面的哈希表中进行介绍* - -*** - -### 链表 - -前面我们介绍了如何使用数组实现线性表,我们接着来看第二种方式,我们可以使用链表来实现,那么什么是链表呢? - -![image-20220723171648380](https://s2.loli.net/2022/07/23/ruemiRQplVy7q9s.png) - -链表不同于顺序表,顺序表底层采用数组作为存储容器,需要分配一块连续且完整的内存空间进行使用,而链表则不需要,它通过一个指针来连接各个分散的结点,形成了一个链状的结构,每个结点存放一个元素,以及一个指向下一个结点的指针,通过这样一个一个相连,最后形成了链表。它不需要申请连续的空间,只需要按照顺序连接即可,虽然物理上可能不相邻,但是在逻辑上依然是每个元素相邻存放的,这样的结构叫做链表(单链表)。 - -链表分为带头结点的链表和不带头结点的链表,戴头结点的链表就是会有一个头结点指向后续的整个链表,但是头结点不存放数据: - -![image-20220723180221112](https://s2.loli.net/2022/07/23/gRUEfOqbtrGN2JZ.png) - -而不带头结点的链表就像上面那样,第一个节点就是存放数据的结点,一般设计链表都会采用带头结点的结构,因为操作更加方便。 - -那么我们就来尝试编写一个带头结点的链表: - -```c -typedef int E; //这个还是老样子 - -struct ListNode { - E element; //保存当前元素 - struct ListNode * next; //指向下一个结点的指针 -}; - -typedef struct Node * Node; //这里我们直接为结点指针起别名,可以直接作为表实现 -``` - -同样的,我们先将初始化函数写好: - -```c -void initList(Node head){ - head->next = NULL; //头结点默认下一个为NULL -} - -int main() { - struct ListNode head; //这里创建一个新的头结点,头结点不存放任何元素,只做连接,连接整个链表 - initList(&head); //先进行初始化 -} -``` - -接着我们来设计一下链表的插入和删除,我们前面实现了顺序表的插入,那么链表的插入该怎么做呢? - -![image-20220723175548491](https://s2.loli.net/2022/07/23/71dgFSWDfoELiXB.png) - -我们可以先修改新插入的结点的后继结点(也就是下一个结点)指向,指向原本在这个位置的结点: - -![image-20220723220552680](https://s2.loli.net/2022/07/23/8MNURYiacWZqwu6.png) - -接着我们可以将前驱结点(也就是上一个结点)的后继结点指向修改为我们新插入的结点: - -![image-20220723175745472](https://s2.loli.net/2022/07/23/ysETUJb6cgBz2Qx.png) - -这样,我们就成功插入了一个新的结点,现在新插入的结点到达了原本的第二个位置上: - -![image-20220723175842075](https://s2.loli.net/2022/07/23/Kb7jCiWa3o4AN8D.png) - -按照这个思路,我们来实现一下,首先设计一下函数: - -```c -void insertList(Node head, E element, int index){ - //head是头结点,element为待插入元素,index是待插入下标 -} -``` - -接着我们需要先找到待插入位置的前驱结点: - -```c -_Bool insertList(Node head, E element, int index){ - if(index < 1) return 0; //如果插入的位置小于1,那肯定是非法的 - while (--index) { //通过--index的方式不断向后寻找前驱结点 - head = head->next; //正常情况下继续向后找 - if(head == NULL) return 0; - //如果在寻找的过程中发型已经没有后续结点了,那么说明index超出可插入的范围了,也是非法的,直接润 - } - - return 1; -} -``` - -在循环操作完成后,如果没问题那么会找到对应插入位置的前驱结点,我们只需要按照上面分析的操作来编写代码即可: - -```c -_Bool insertList(Node head, E element, int index){ - if(index < 1) return 0; - while (--index) { - head = head->next; - if(head == NULL) return 0; - } - Node node = malloc(sizeof (struct ListNode)); - if(node == NULL) return 0; //创建一个新的结点,如果内存空间申请失败返回0 - node->element = element; //将元素保存到新创建的结点中 - node->next = head->next; //先让新插入的节点指向原本位置上的这个结点 - head->next = node; //接着将前驱结点指向新的这个结点 - return 1; -} -``` - -这样,我们就编写好了链表的插入操作了,我们可以来测试一下: - -```c -void printList(Node head){ - while (head->next) { - head = head->next; - printf("%d ", head->element); //因为头结点不存放数据,所以从第二个开始打印 - } -} - -int main() { - struct ListNode head; - initList(&head); - for (int i = 0; i < 3; ++i) { - insertList(&head, i * 100, i); //依次插入3个元素 - } - printList(&head); //打印一下看看 -} -``` - -成功得到结果: - -![image-20220723222147977](https://s2.loli.net/2022/07/23/1D94PILFxC52vRQ.png) - -那么链表的插入我们研究完了,接着就是结点的删除了,那么我们如何实现删除操作呢?实际上也会更简单一些,我们可以直接将待删除节点的前驱结点指向修改为待删除节点的下一个: - -![image-20220723222922058](https://s2.loli.net/2022/07/23/N5sZx9T2a8lOzoC.png) - -![image-20220723223103306](https://s2.loli.net/2022/07/23/tNYnBJe9pczUq1Z.png) - -这样,在逻辑上来说,待删除结点其实已经不在链表中了,所以我们只需要释放掉待删除结点占用的内存空间就行了: - -![image-20220723223216420](https://s2.loli.net/2022/07/23/MFE2gZuS5eOysDW.png) - -那么我们就按照这个思路来编写一下程序,首先还是设计函数: - -```c -void deleteList(Node head, int index){ - //head就是头结点,index依然是待删除的结点位序 -} -``` - -首先我们还是需要找到待删除结点的前驱结点: - -```c -_Bool deleteList(Node head, int index){ - if(index < 1) return 0; //大体和上面是一样的 - while (--index) { - head = head->next; - if(head == NULL) return 0; - } - if(head->next == NULL) return 0; //注意删除的范围,如果前驱结点的下一个已经是NULL了,那么也说明超过了范围 - - return 1; -} -``` - -最后就是按照我们上面说的删除结点了: - -```c -_Bool deleteList(Node head, int index){ - if(index < 0) return 0; - while (index--) { - head = head->next; - if(head == NULL) return 0; - } - if(head->next == NULL) return 0; - Node tmp = head->next; //先拿到待删除结点 - head->next = head->next->next; //直接让前驱结点指向下一个的下一个结点 - free(tmp); //最后使用free函数释放掉待删除结点的内存 - return 1; -} -``` - -这样,我们就成功完成了链表的删除操作: - -```c -int main() { - struct ListNode head; - initList(&head); - for (int i = 0; i < 3; ++i) { - insertList(&head, i * 100, i); - } - deleteList(&head, 0); //这里我们尝试删除一下第一个元素 - printList(&head); -} -``` - -最后得到结果也是正确的: - -![image-20220723224653754](https://s2.loli.net/2022/07/23/jnOKy6ls8wAqrHJ.png) - -接着就是链表的一些其他操作了,这里我们也来实现一下,首先是获取对应位置上的元素: - -```c -E * getList(Node head, int index){ - if(index < 1) return NULL; //如果小于0那肯定不合法,返回NULL - do { - head = head->next; //因为不算头结点,所以使用do-while语句 - if(head == NULL) return NULL; //如果已经超出长度那肯定也不行 - } while (--index); //到达index就结束 - return &head->element; -} -``` - -接着是查找对应元素的位置: - -```c -int findList(Node head, E element){ - head = head->next; //先走到第一个结点 - int i = 1; //计数器 - while (head) { - if(head->element == element) return i; //如果找到,那么就返回i - head = head->next; //没找到就继续向后看 - i++; //i记住要自增 - } - return -1; //都已经走到链表尾部了,那么就确实没找到了,返回-1 -} -``` - -接着是求链表的长度,这个太简单了: - -```c -int sizeList(Node head){ - int i = 0; //从0开始 - while (head->next) { //如果下一个为NULL那就停止 - head = head->next; - i++; //每向后找一个就+1 - } - return i; -} -``` - -这样,我们的链表就编写完成了,整个代码如下: - -```c -#include -#include - -typedef int E; - -struct ListNode { - E element; - struct ListNode * next; -}; - -typedef struct ListNode * Node; - -void initList(Node node){ - node->next = NULL; -} - -_Bool insertList(Node head, E element, int index){ - if(index < 1) return 0; - while (--index) { - head = head->next; - if(head == NULL) return 0; - } - - Node node = malloc(sizeof(struct ListNode)); - if(node == NULL) return 0; - node->element = element; - node->next = head->next; - head->next = node; - return 1; -} - -_Bool deleteList(Node head, int index){ - if(index < 1) return 0; //大体和上面是一样的 - while (--index) { - head = head->next; - if(head == NULL) return 0; - } - if(head->next == NULL) return 0; - - Node tmp = head->next; - head->next = head->next->next; - free(tmp); - return 1; -} - -E * getList(Node head, int index){ - if(index < 1) return 0; - do { - head = head->next; - if(head == NULL) return 0; - } while (--index); - return &head->element; -} - -int findList(Node head, E element){ - head = head->next; - int i = 1; - while (head) { - if(head->element == element) return i; - head = head->next; - i++; - } - return -1; -} - -int sizeList(Node head){ - int i = -1; - while (head) { - head = head->next; - i++; - } - return i; -} -``` - -**问题:**请问链式实现的线性表,插入、删除、获取元素操作的时间复杂度为? - -* **插入:**因为要寻找对应位置的前驱结点,所以平均时间复杂度为$O(n)$,但是不需要做任何的移动操作,效率肯定是比顺序表要高的。 -* **删除:**同上,所以平均时间复杂度为$O(n)$ -* **获取元素:**由于必须要挨个向后寻找,才能找到对应的结点,所以时间复杂度为$O(n)$,不支持随机访问,只能顺序访问,比顺序表慢。 - -**问题**:什么情况下使用顺序表,什么情况下使用链表呢? - -* 通过分析顺序表和链表的特性我们不难发现,链表在随机访问元素时,需要通过遍历来完成,而顺序表则利用数组的特性直接访问得到,所以,当我们读取数据多于插入或是删除数据的情况下时,使用顺序表会更好。 -* 而顺序表在插入元素时就显得有些鸡肋了,因为需要移动后续元素,整个移动操作会浪费时间,而链表则不需要,只需要修改结点 指向即可完成插入,所以在频繁出现插入或删除的情况下,使用链表会更好。 - -**链表练习题:** - -1. 在一个长度为`n (n>1)`的单链表上,设有头和尾两个指针,执行( )操作与链表的长度有关? - - A.删除单链表中的第一个元素 - B.删除单链表中的最后一个元素 - C.在单链表第一个元素前插入一个新元素 - D.在单链表最后一个元素后插入一个新元素 - - *注意题干,现在有指向链表头尾的两个指针,那么A、C肯定是可以直接通过头结点找到的,无论链表长度如何都不影响,D也可以直接通过尾指针进行拼接,只有B需要尾指针的前驱结点,此时只能从头开始遍历得到,所以选择B* - -2. 在一个单链表HL中(HL为头结点指针),若要向表头插入一个由指针p指向的结点,则执行? - - A. HL=p; p->next=HL; - B. p->next=HL; HL=p; - C. p->next=HL; p=HL; - D. p->next=HL->next; HL->next=p; - - *既然要在表头插入一个数据,也就是说要在第一个位置插入,那么根据我们之前讲解的链表的插入,只需要将头结点指向新的结点,再让新的结点指向原本的第一个结点即可,所以选择D* - -3. 链表不具备的特点是? - - A.可随机访问任一结点 B.插入删除不需要移动元素 - C.不必事先估计存储空间 D.所需空间与其长度成正比 - - *我们前面说了,链表由于是链式存储结构,无法直接访问到对应下标的元素,所以我们只能通过遍历去找到对应位置的元素,故选择A* - -*** - -### 双向链表和循环链表 - -前面我们介绍了单链表,通过这样的链式存储,我们不用再像顺序表那样一次性申请一段连续的空间,而是只需要单独为结点申请内存空间,同时在插入和删除的速度上也比顺序表轻松。不过有一个问题就是,如果我们想要操作某一个结点,比如删除或是插入,那么由于单链表的性质,我们只能先去找到它的前驱结点,才能进行。 - -为了解决这种查找前驱结点非常麻烦的问题,我们可以让结点不仅保存指向后续结点的指针,同时也保存指向前驱结点的指针: - -![image-20220724123947104](https://s2.loli.net/2022/07/24/oeXm6nyW7I9lPMf.png) - -这样我们无论在哪个结点,都能够快速找到对应的前驱结点,就很方便了,这样的链表我们成为双向链表(双链表) - -这里我们也来尝试实现一下,首先定义好结构体: - -```c -typedef int E; - -struct ListNode { - E element; //保存当前元素 - struct ListNode * next; //指向下一个结点的指针 - struct ListNode * prev; //指向上一个结点的指针 -}; - -typedef struct ListNode * Node; -``` - -接着是初始化方法,在初始化时需要将前驱和后继都设置为NULL: - -```c -void initNode(Node node){ - node->next = node->prev = NULL; -} - -int main() { - struct ListNode head; - initNode(&head); -} -``` - -接着是双向链表的插入操作,这就比单链表要麻烦一些了,我们先来分析一下: - -![image-20220724125739857](https://s2.loli.net/2022/07/24/MYwlVZ2fXB6icPt.png) - -首先我们需要考虑后继结点,当新的结点插入之后,新的结点的后继结点就是原本在此位置上的结点,所以我们可以先将待插入结点的后继指针指向此位置上的结点: - -![image-20220724130010432](https://s2.loli.net/2022/07/24/IDYwp5gdPcSyFQO.png) - -由于是双向链表,所以我们需要将原本在此位置上的结点的前驱指针指向新的结点: - -![image-20220724130219180](https://s2.loli.net/2022/07/24/5CKQ6LnzxGm4pYd.png) - -接着我们来处理一下前驱结点,首先将前驱结点的后继指针修改为新的结点: - -![image-20220724130342232](https://s2.loli.net/2022/07/24/vmEViApU36FonJz.png) - - 最后我们将新的结点的前驱指针指向前驱结点即可: - -![image-20220724130442927](https://s2.loli.net/2022/07/24/C65IuomOVdAaWZ8.png) - -这样,我们就完成了双向链表中结点的插入操作,按照这个思路,我们来设计一下函数吧: - -```c -_Bool insertList(Node head, E element, int index){ - if(index < 1) return 0; //跟单链表一样,还是先找到对应的位置 - while (--index) { - head = head->next; - if(head == NULL) return 0; - } - Node node = malloc(sizeof (struct ListNode)); //创建新的结点 - if(node == NULL) return 0; - node->element = element; - - if(head->next) { //首先处理后继结点,现在有两种情况,一种是后继结点不存在的情况,还有一种是后继结点存在的情况 - head->next->prev = node; //如果存在则修改对应的两个指针 - node->next = head->next; - } else { - node->next = NULL; //不存在直接将新结点的后继指针置为NULL - } - - head->next = node; //接着是前驱结点,直接操作就行 - node->prev = head; - return 1; -} -``` - -这样,我们就编写好了双向链表的插入操作,来测试一下吧: - -```c -int main() { - struct ListNode head; - initNode(&head); - for (int i = 0; i < 5; ++i) //插5个元素吧 - insertList(&head, i * 100, i); - - Node node = &head; //先来正向遍历一次 - do { - node = node->next; - printf("%d -> ", node->element); - } while (node->next != NULL); - - printf("\n"); //再来反向遍历一次 - do { - printf("%d -> ", node->element); - node = node->prev; - } while (node->prev != NULL); -} -``` - -可以看到结果没有问题: - -![image-20220724132205136](https://s2.loli.net/2022/07/24/DZ7zStdGB4EsvaW.png) - -无论是正向遍历还是反向遍历,都可以正常完成,相比单链表的灵活度肯定是更大的,我们接着来看删除操作,其实删除操作也是差不多的方式: - -![image-20220724132636580](https://s2.loli.net/2022/07/24/WgxjXBDAalYFGSH.png) - -我们只需将前驱结点和后继结点的指向修改即可: - -![image-20220724132801105](https://s2.loli.net/2022/07/24/3aU7zV1N5Mox2Qk.png) - -接着直接删除对应的结点即可: - -![image-20220724132906001](https://s2.loli.net/2022/07/24/L1zCq26k5BaGOlm.png) - -现在我们就来编码吧: - -```c -_Bool deleteList(Node head, int index){ - if(index < 1) return 0; //跟单链表一样,还是先找到对应的位置 - while (--index) { - head = head->next; - if(head == NULL) return 0; - } - if(head->next == NULL) return 0; - - Node tmp = head->next; //先拿到待删除结点 - if(head->next->next) { //这里有两种情况待删除结点存在后继结点或是不存在 - head->next->next->prev = head; - head->next = head->next->next; //按照上面分析的来 - }else{ - head->next = NULL; //相当于删的是最后一个结点,所以直接后继为NULL就完事 - } - free(tmp); //最后释放已删除结点的内存 - return 1; -} -``` - -这样,我们就实现了双向链表的插入和删除操作,其他操作这里就不演示了。 - -接着我们再来简单认识一下另一种类型的链表,循环链表,这种链表实际上和前面我们讲的链表是一样的,但是它的最后一个结点,是与头结点相连的,双向链表和单向链表都可以做成这样的环形结构,我们这里以单链表为例: - -![image-20220724134153904](https://s2.loli.net/2022/07/24/KZl4SJVYQ5cfv7b.png) - -这种类型的链表实际上与普通链表的唯一区别就在于最后是否连接到头结点,因此循环链表支持从任意一个结点出发都可以到达任何的结点,而普通的链表则只能从头结点出发才能到达任意结点,同样也是为了更灵活而设计的。 - -**链表练习题:** - -1. 与单链表相比,双链表的优点之一是? - - A.插入、删除操作更简单 - B.可以进行随机访问 - C.可以省略表头指针或表尾指针 - D.顺序访问相邻结点更灵活 - - *首先插入删除操作并没有更简单,反而更复杂了,随机访问肯定也是不行的,省略表头表尾指针实际上单链表也可以,所以直接冲D就完事了* - -2. 非空的循环单链表head的尾结点(由p所指向)满足? - - A.p->next == NULL B.p == NULL - C.p->next ==head D.p == head - - *前面我们说了,循环链表实际上唯一区别就是尾部的下一个结点会指向头部,所以这里选择C* - -3. 若某表最常用的操作是在最后一个结点之后插入一个结点或删除最后一个结点,则采用什么存储方式最节省运算时间? - - A.单链表 B.给出表头指针的单循环链表 C.双链表 D.带头结点的双循环链表 - - *题干说明了常用的是在尾结点插入或删除尾结点,那么此时不仅需要快速找到最后一个结点,也需要快速找到最后一个结点的前驱结点,所以肯定是使用双向链表,为了快速找到尾结点,使用循环双向链表从头结点直接向前就能找到,所以选择D* - -4. 如果对线性表的操作只有两种,即删除第一个元素,在最后一个元素的后面插入新元素,则最好使用? - - A.只有表头指针没有表尾指针的循环单链表 - B.只有表尾指针没有表头指针的循环单链表 - C.非循环双链表 - D.循环双链表 - - *首先这里需要操作两个内容,一个是删除第一个元素,另一个是在最后插入新元素,所以A的话只有表头指针虽然循环但是还是得往后遍历才行,而B正好符合,因为循环链表的尾指针可以快速到达头结点,C不可能,D的话,循环双链表也可以,但是没有单链表节省空间,故B是最优解* - -*** - -## 特殊线性表 - -前面我们讲解的基础的线性表,通过使用线性表,我们就可以很方便地对数据进行管理了。这一部分,我们将继续认识一些特殊的线性表,它有着特别的规则,在特定场景有着很大的作用,也是考察的重点。 - -### 栈 - -栈(也叫堆栈,Stack)是一种特殊的线性表,它只能在在表尾进行插入和删除操作,就像下面这样: - -![image-20220724210955622](https://s2.loli.net/2022/07/24/D3heysaM9EpAgS4.png) - -也就是说,我们只能在一端进行插入和删除,当我们依次插入1、2、3、4这四个元素后,连续进行四次删除操作,删除的顺序刚好相反:4、3、2、1,我们一般将其竖着看: - -![image-20220724211442421](https://s2.loli.net/2022/07/24/2NxUpCIRLoZt9Ky.png) - -底部称为栈底,顶部称为栈顶,所有的操作只能在栈顶进行,也就是说,被压在下方的元素,只能等待其上方的元素出栈之后才能取出,就像我们往箱子里里面放的书一样,因为只有一个口取出里面的物品,所以被压在下面的书只能等上面的书被拿出来之后才能取出,这就是栈的思想,它是一种先进后出的数据结构(FILO,First In, Last Out) - -实现栈也是非常简单的,可以基于我们前面的顺序表或是链表,这里我们先使用顺序表来实现一下,这里我们需要实现两个新的操作: - -* pop:出栈操作,从栈顶取出一个元素。 -* push:入栈操作,向栈中压入一个新的元素。 - -首先还是按照我们的顺序表进行编写: - -```c -typedef int E; - -struct Stack { - E * array; - int capacity; - int top; //这里使用top来表示当前的栈顶位置,存的是栈顶元素的下标 -}; - -typedef struct Stack * ArrayStack; //起个别名 -``` - -接着我们需要编写一个初始化方法: - -```c -_Bool initStack(ArrayStack stack){ - stack->array = malloc(sizeof(E) * 10); - if(stack->array == NULL) return 0; - stack->capacity = 10; //容量还是10 - stack->top = -1; //由于栈内没有元素,那么栈顶默认就为-1 - return 1; -} -``` - -```c -int main(){ - struct Stack stack; - initStack(&stack); -} -``` - -接着就是栈的两个操作了,一个是入栈操作,一个是出栈操作: - -```c -_Bool pushStack(ArrayStack stack, E element){ - //入栈操作只需要给元素就可以,不需要index,因为只能从尾部入栈 -} -``` - -由于入栈只能在尾部插入,所以就很好写了: - -```c -_Bool pushStack(ArrayStack stack, E element){ - stack->array[stack->top + 1] = element; //直接设定栈顶元素 - stack->top++; //栈顶top变量记得自增 - return 1; -} -``` - -我们来测试一下吧: - -```c -void printStack(ArrayStack stack){ - printf("| "); - for (int i = 0; i < stack->top + 1; ++i) { - printf("%d, ", stack->array[i]); - } - printf("\n"); -} - -int main(){ - struct Stack stack; - initStack(&stack); - for (int i = 0; i < 3; ++i) { - pushStack(&stack, i*100); - } - printStack(&stack); -} -``` - -测试结果也是正确的: - -![image-20220724215755986](https://s2.loli.net/2022/07/24/cDwAgi8FnyQBpRT.png) - -可以看到,从栈底到栈顶一次是0、100、200,不过我们现在的`push`操作还不够完美,因为栈有可能塞满,所以要进行扩容处理: - -```c -_Bool pushStack(ArrayStack stack, E element){ - if(stack->top + 1 == stack->capacity) { //栈顶+1如果等于容量的话,那么说明已经塞满了 - int newCapacity = stack->capacity + (stack->capacity >> 1); //大体操作和顺序表一致 - E * newArray = realloc(stack->array, newCapacity * sizeof(E)); - if(newArray == NULL) return 0; - stack->array = newArray; - stack->capacity = newCapacity; - } - stack->array[stack->top + 1] = element; - stack->top++; - return 1; -} -``` - -这样我们的入栈操作就编写完成了,接着是出栈操作,出栈操作我们只需要将栈顶元素取出即可: - -```c -_Bool isEmpty(ArrayStack stack){ //在出栈之前,我们还需要使用isEmpty判断一下栈是否为空,空栈元素都没有出个毛 - return stack->top == -1; -} - -E popStack(ArrayStack stack){ - return stack->array[stack->top--]; //直接返回栈顶元素,注意多加一个自减操作 -} -``` - -我们来测试一下吧: - -```c -int main(){ - struct Stack stack; - initStack(&stack); - for (int i = 0; i < 3; ++i) { - pushStack(&stack, i*100); - } - printStack(&stack); - while (!isEmpty(&stack)) { - printf("%d ", popStack(&stack)); //将栈中所有元素依次出栈 - } -} -``` - -可以看到,出栈顺序和入栈顺序是完全相反的: - -![image-20220724221238281](https://s2.loli.net/2022/07/24/U1SrtmFs3ibGO78.png) - -当然使用数组实现栈除了这种可以自己扩容的之外,也有固定大小的栈,当栈已满时,就无法再进行入栈操作了。 - -不过有些时候,栈的利用率可能会很低,这个时候我们可以将一个固定长度的数组共享给两个栈来使用: - -![image-20220724221917968](https://s2.loli.net/2022/07/24/HRveZ8Ed2TrtaC7.png) - -数组的两头分别作为两个栈的栈底,当两个栈的栈顶指针相遇时(栈顶指针下标之差绝对值为1时),表示栈已满。通过这种方式,我们就可以将数组占用的空间更充分地使用,这样的栈我们称为**共享栈**。 - -前面我们演示了使用顺序表实现栈,我们接着来看如何使用链表来实现栈,实际上使用链表会更加的方便,我们可以直接将头结点指向栈顶结点,而栈顶结点连接后续的栈内结点: - -![image-20220724222836333](https://s2.loli.net/2022/07/24/outf2S7D3WzQK8c.png) - -当有新的元素入栈,只需要在链表头部插入新的结点即可,我们来尝试编写一下: - -```c -typedef int E; - -struct ListNode { - E element; - struct ListNode * next; -}; - -typedef struct ListNode * Node; - -void initStack(Node head){ - head->next = NULL; -} - -int main(){ - struct ListNode head; - initStack(&head); -} -``` - -接着我们来编写一下入栈操作: - -![image-20220724223550553](https://s2.loli.net/2022/07/24/GdBj3g5YRFzSsVw.png) - -代码如下: - -```c -_Bool pushStack(Node head, E element){ - Node node = malloc(sizeof(struct ListNode)); //创建新的结点 - if(node == NULL) return 0; //失败就返回0 - node->next = head->next; //将当前结点的下一个设定为头结点的下一个 - node->element = element; //设置元素 - head->next = node; //将头结点的下一个设定为当前结点 - return 1; -} -``` - -我们来编写一个测试: - -```c -void printStack(Node head){ - printf("| "); - head = head->next; - while (head){ - printf("%d ", head->element); - head = head->next; - } - printf("\n"); -} - -int main(){ - struct ListNode head; - initStack(&head); - for (int i = 0; i < 3; ++i) { - pushStack(&head, i*100); - } - printStack(&head); -} -``` - -可以看到结果没有问题: - -![image-20220724224644876](https://s2.loli.net/2022/07/24/fy6ZCNqd3eJYIrG.png) - -其实出栈也是同理,所以我们只需要将第一个元素移除即可: - -```c -_Bool isEmpty(Node head){ - return head->next == NULL; //判断栈是否为空只需要看头结点下一个是否为NULL即可 -} - -E popStack(Node head){ - Node top = head->next; - head->next = head->next->next; - E e = top->element; - free(top); //别忘了释放结点的内存 - return e; //返回出栈元素 -} -``` - -这里我们来测试一下: - -```c -int main(){ - struct ListNode head; - initStack(&head); - for (int i = 0; i < 3; ++i) { - pushStack(&head, i*100); - } - printStack(&head); - while (!isEmpty(&head)) { - printf("%d ", popStack(&head)); //将栈中所有元素依次出栈 - } -} -``` - -![image-20220724225005605](https://s2.loli.net/2022/07/24/xjOvlieXr2V9BZg.png) - -实际上无论使用链表还是顺序表,都可以很轻松地实现栈,因为栈的插入和删除操作很特殊。 - -**栈练习题:** - -1. 若进栈序列为1,2,3,4,则不可能得到的出栈序列是? - - A. 3,2,1,4 B. 3,2,4,1 - C. 4,2,3,1 D. 2,3,4,1 - - *注意进栈并不一定会一次性全部进栈,可能会出现边进边出的情况,所以出栈的顺序可能有很多种情况,首先来看A,第一个出栈的是3,那么按照顺序,说明前面一定入栈了2、1,在出栈时4还没有入栈,然后是2、1最后是4,没有问题。接着是B,跟前面的A一样,不过这次是先出站3、2,而1留在栈中,接着4入栈,然后再让4、1出栈,也是正确的。然后是C,首先是4出栈,那么说明前三个一定都入栈了,而此时却紧接着的一定是3,而这里是2,错误。所以选择C* - -2. 假设有5个整数以1、2、3、4、5的顺序被压入堆栈,且出栈顺序为3、5、4、2、1,那么栈大小至少为? - - A.2 - B.3 - C.4 - D.5 - - *首先我们分析一下,第一个出栈的元素为3,那么也就是说前面的1、2都在栈内,所以大小至少为3,然后是5,那么说明此时栈内为1、2、4,算是出栈的5,那么至少需要的大小就是4了,所以选择C* - -### 队列 - -前面我们学习了栈,栈中元素只能栈顶出入,它是一种特殊的线性表,同样的,队列(Queue)也是一种特殊的线性表。 - -就像我们在超市、食堂需要排队一样,我们总是排成一列,先到的人就排在前面,后来的人就排在后面,越前面的人越先完成任务,这就是队列,队列有队头和队尾: - -![image-20220725103600318](https://s2.loli.net/2022/07/25/xBuZckTNtR54AEq.png) - -秉承先来后到的原则,队列中的元素只能从队尾进入,只能从队首出去,也就是说,入队顺序为1、2、3、4,那么出队顺序也一定是1、2、3、4,所以队列是一种先进先出(FIFO,First In, First Out)的数据结构。 - -想要实现队列也是很简单的,也可以通过两种线性表来实现,我们先来看看使用顺序表如何实现队列,假设一开始的时候队列中有0个元素,队首和队尾一般都初始都是-1这个位置: - -![image-20220725110033373](https://s2.loli.net/2022/07/25/OKVFSEfQIkDjzNu.png) - -此时有新的元素入队了,队尾向后移动一格(+1),然后在所指向位置插入新的元素: - -![image-20220725110155810](https://s2.loli.net/2022/07/25/Pd6ZRUxKIhzVF9E.png) - -之后都是同样的方式进行插入,队尾会一直向后移动: - -![image-20220725110910388](https://s2.loli.net/2022/07/25/8w3Mlroz25EeIcL.png) - -现在我们想要执行出队操作了,那么需要将队首向后移动一格,然后删除队首指向的元素: - -![image-20220725111826355](https://s2.loli.net/2022/07/25/LaZsrtwi8AkW9gh.png) - -看起来设计的还挺不错的,不过这样有一个问题,这个队列是一次性的,如果队列经过反复出队入队操作,那么最后指针会直接指向数组的最后,如果我们延长数组的话,也不是一个办法,不可能无限制的延伸下去吧?所以一般我们采用循环队列的形式,来实现重复使用一个数组(不过就没办法扩容了,大小是固定的) - -![image-20220725112931675](https://s2.loli.net/2022/07/25/MNaqpZRgkHcTlCU.png) - -我们可以在移动队首队尾指针时,考虑循环的问题,也就是说如果到达了数组尽头,那么就直接从数组的前面重新开始计算,这样就相当于逻辑上都循环了,队首和队尾指针在一开始的时候都指向同一个位置,每入队一个新的元素,依然是先让队尾后移一位,在所指向位置插入元素,出队同理。 - -不过这样还是有问题,既然是循环的,那么怎么判断队列是否已满呢? - -![image-20220725113824587](https://s2.loli.net/2022/07/25/eptxXASywr3b4c9.png) - -由于队首指针和队尾指针重合时表示队列为空,所以我们只能舍弃一个存储单元,当队尾距离队首一个单元的时候,表示队列已满。 - -好了,现在理论讲解完毕,我们可以开始编写代码了: - -```c -typedef int E; - -struct Queue { - E * array; - int capacity; //数组容量 - int rear, front; //队尾、队首指针 -}; - -typedef struct Queue * ArrayQueue; -``` - -接着我们来对其进行初始化: - -```c -_Bool initQueue(ArrayQueue queue){ - queue->array = malloc(sizeof(E) * 10); - if(queue->array == NULL) return 0; - queue->capacity = 10; - queue->front = queue->rear = 0; //默认情况下队首和队尾都指向0的位置 - return 1; -} - -int main(){ - struct Queue queue; - initQueue(&queue); -} -``` - -接着我们来编写一下入队操作: - -```c -_Bool offerQueue(ArrayQueue queue, E element){ - if((queue->rear + 1) % queue->capacity == queue->front) //先判断队列是否已满,如果队尾下一个就是队首,那么说明已满 - return 0; - queue->rear = (queue->rear + 1) % queue->capacity; //队尾先向前移动一位,注意取余计算才能实现循环 - queue->array[queue->rear] = element; //在新的位置插入元素 - return 1; -} -``` - -我们来测试一下: - -```c -void printQueue(ArrayQueue queue){ - printf("<<< "); - int i = queue->front; //遍历队列需要从队首开始 - do { - i = (i + 1) % queue->capacity; //先向后循环移动 - printf("%d ", queue->array[i]); //然后打印当前位置上的元素 - } while (i != queue->rear); //当到达队尾时,结束 - printf("<<<\n"); -} - -int main(){ - struct Queue queue; - initQueue(&queue); - for (int i = 0; i < 5; ++i) { - offerQueue(&queue, i * 100); - } - printQueue(&queue); -} -``` - -最后结果如下: - -![image-20220725143455025](https://s2.loli.net/2022/07/25/zLRWSAH8OaTgFBv.png) - -我们接着来看出队操作: - -```c -_Bool isEmpty(ArrayQueue queue){ //在出队之前需要先看看容量是否足够 - return queue->rear == queue->front; -} - -E pollQueue(ArrayQueue queue){ - queue->front = (queue->front + 1) % queue->capacity; //先将队首指针后移 - return queue->array[queue->front]; //出队,完事 -} -``` - -我们来测试一下吧: - -```c -int main(){ - struct Queue queue; - initQueue(&queue); - for (int i = 0; i < 5; ++i) { - offerQueue(&queue, i * 100); - } - printQueue(&queue); - while (!isEmpty(&queue)) { - printf("%d ", pollQueue(&queue)); - } -} -``` - -我们来看看结果: - -![image-20220725144733780](https://s2.loli.net/2022/07/25/45dI2h7iWPuQJRp.png) - -可以看到,队列是先进先出的,我们是以什么顺序放入队列中,那么出来的就是是什么顺序。 - -同样的,队列也可以使用链表来实现,并且使用链表的话就不需要关心容量之类的问题了,会更加灵活一些: - -![image-20220725145214955](https://s2.loli.net/2022/07/25/lwGgHXqAV5z2KNk.png) - -注意我们需要同时保存队首和队尾两个指针,因为是单链表,所以队首需要存放指向头结点的指针,因为需要的是前驱结点,而队尾则直接是指向尾结点的指针即可,后面只需要直接在后面拼接就行。 - -当有新的元素入队时,只需要拼在队尾就行了,同时队尾指针也要后移一位: - -![image-20220725145608827](https://s2.loli.net/2022/07/25/ufmFEwrS9xVKoIZ.png) - -出队时,只需要移除队首指向的下一个元素即可: - -![image-20220725145707707](https://s2.loli.net/2022/07/25/geJRFwHKhGT69XD.png) - -那么我们就按照这个思路,来编写一下代码吧: - -```c -typedef int E; - -struct LNode { - E element; - struct LNode * next; -}; - -typedef struct LNode * Node; - -struct Queue{ - Node front, rear; -}; - -typedef struct Queue * LinkedQueue; //因为要存储首位两个指针,所以这里封装一个新的结构体吧 -``` - -接着是初始化,初始化的时候,需要把头结点先创建出来: - -```c -_Bool initQueue(LinkedQueue queue){ - Node node = malloc(sizeof(struct LNode)); - if(node == NULL) return 0; - node->next = NULL; - queue->front = queue->rear = node; //一开始两个指针都是指向头结点的,表示队列为空 - return 1; -} - -int main(){ - struct Queue queue; - initQueue(&queue); -} -``` - -首先是入队操作,入队其实直接在后面插入新的结点就行了: - -```c -_Bool offerQueue(LinkedQueue queue, E element){ - Node node = malloc(sizeof(struct LNode)); - if(node == NULL) return 0; - node->next = NULL; - node->element = element; - queue->rear->next = node; //先让尾结点的下一个指向新的结点 - queue->rear = node; //然后让队尾指针指向新的尾结点 - return 1; -} -``` - -我们来测试一下看看: - -```c -void printQueue(LinkedQueue queue){ - printf("<<< "); - Node node = queue->front->next; - while (1) { //注意不能直接判空,因为前面我们没考虑,也就没将新结点next设定为NULL - printf("%d ", node->element); - if(node == queue->rear) break; //当已经打印最后一个元素后,再结束 - else node = node->next; - } - printf("<<<\n"); -} - -int main(){ - struct Queue queue; - initQueue(&queue); - for (int i = 0; i < 5; ++i) { - offerQueue(&queue, i*100); - } - printQueue(&queue); -} -``` - -测试结果如下: - -![image-20220725151434438](https://s2.loli.net/2022/07/25/SqeNUgimC4I5aZD.png) - -接着是出队操作,出队操作要相对麻烦一点: - -```c -E pollQueue(LinkedQueue queue){ - E e = queue->front->next->element; - Node node = queue->front->next; - queue->front->next = queue->front->next->next; //直接让头结点指向下下个结点 - if(queue->rear == node) queue->rear = queue->front; //如果队尾就是待出队的结点,那么队尾回到队首位置上 - free(node); //释放内存 - return e; -} -``` - -这样,我们就编写好了: - -```c -int main(){ - struct Queue queue; - initQueue(&queue); - for (int i = 0; i < 5; ++i) { - offerQueue(&queue, i*100); - } - printQueue(&queue); - while (!isEmpty(&queue)){ - printf("%d ", pollQueue(&queue)); - } -} -``` - -测试结果如下: - -![image-20220725152020131](https://s2.loli.net/2022/07/25/KT8mn2RkxPvgZuF.png) - -效果和前面的数组实现是一样的,只不过使用链表会更加灵活一些。 - -**队列练习题:** - -1. 使用链表方式存储的队列,在进行出队操作时需要? - - A. 仅修改头结点指向 B. 仅修改尾指针 C. 头结点指向、尾指针都要修改 D. 头结点指向、尾指针可能都要修改 - - *首先出队肯定是要动头结点指向的,但是不一定需要动尾指针,因为只有当尾指针指向的是待出队的元素时才需要,因为执行后队列就为空了,所以需要将队尾指针移回头结点处,选择D* - -2. 引起循环队列队头位置发生变化的操作是? - - A. 出队 - - B. 入队 - - C. 获取队头元素 - - D. 获取队尾元素 - - *这个题还是很简单的,因为只有出队操作才会使得队头位置后移,所以选择A* - -*** - -## 算法实战 - -欢迎来到线性结构篇算法实战,这一部分我们将从算法相关题目上下手,解决实际问题,其中链表作为重点考察项目。 - -### (简单)删除链表中重复元素 - -本题来自LeetCode:[83. 删除排序链表中的重复元素](https://leetcode.cn/problems/remove-duplicates-from-sorted-list/) - -给定一个已排序的链表的头 head(注意是无头结点的链表,上来第一个结点就是存放第一个元素) , 删除所有重复的元素,使每个元素只出现一次 。返回已排序的链表 。 - - 示例 1: - -![img](https://assets.leetcode.com/uploads/2021/01/04/list1.jpg) - -> 输入:head = [1,1,2] -> 输出:[1,2] - -示例 2: - -![img](https://assets.leetcode.com/uploads/2021/01/04/list2.jpg) - -> 输入:head = [1,1,2,3,3] -> 输出:[1,2,3] - -这道题实际上比较简单,只是考察各位小伙伴对于链表数据结构的掌握程度,我们只需要牢牢记住如何对链表中的元素进行删除操作就能轻松解决这道题了。 - -```c -struct ListNode* deleteDuplicates(struct ListNode* head){ - if(head == NULL) return head; //首先如果进来的就是NULL,那就不用再浪费时间了 - struct ListNode * node = head; //这里用一个指针来表示当前所指向的结点 - while (node->next != NULL) { //如果结点的下一个为空,就没必要再判断了,否则不断进行判断 - if(node->next->val == node->val) { //如果下一个节点跟当前节点值一样,那么删除下一个节点 - node->next = node->next->next; - } else { - node = node->next; //否则继续从下一个节点开始向后判断 - } - } - return head; //最后原样返回头结点 -} -``` - -### (简单)反转链表 - -本题来自LeetCode:[206. 反转链表](https://leetcode.cn/problems/reverse-linked-list/) - -给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。 - -示例 1: - -![img](https://assets.leetcode.com/uploads/2021/02/19/rev1ex1.jpg) - -> 输入:head = [1,2,3,4,5] -> 输出:[5,4,3,2,1] - -示例 2: - -![img](https://assets.leetcode.com/uploads/2021/02/19/rev1ex2.jpg) - -> 输入:head = [1,2] -> 输出:[2,1] - -这道题依然是考察各位小伙伴对于链表相关操作的掌握程度,我们如何才能将一个链表的顺序进行反转,关键就在于如何修改每个节点的指针指向。 - -```c -struct ListNode* reverseList(struct ListNode* head){ - struct ListNode * newHead = NULL, * tmp; //创建一个指针存放新的头结点(注意默认要为NULL),和一个中间暂存指针 - while (head != NULL) { //这里利用head不断向后遍历,来依次修改每个结点的指向 - tmp = head; //先暂存当前结点 - head = head->next; //head可以先后移了 - tmp->next = newHead; //将暂存节点的下一个节点,指向前一个结点 - newHead = tmp; //最后新的头结点就是tmp所指向结点,这样循环操作直到结束 - } - return newHead; //最后返回新的结点即可 -} -``` - -### (中等)旋转链表 - -本题来自LeetCode:[61. 旋转链表](https://leetcode.cn/problems/rotate-list/) - -给你一个链表的头节点 head ,旋转链表,将链表每个节点向右移动 k 个位置。 - -示例 1: - -![img](https://assets.leetcode.com/uploads/2020/11/13/rotate1.jpg) - -> 输入:head = [1,2,3,4,5], k = 2 -> 输出:[4,5,1,2,3] - -示例 2: - -![img](https://assets.leetcode.com/uploads/2020/11/13/roate2.jpg) - -> 输入:head = [0,1,2], k = 4 -> 输出:[2,0,1] - -这道题需要我们进行一些思考了,首先我们要知道,在经过旋转之后最终的头结点是哪一个,在知道后,这道题就很简单了,我们只需要断掉对应头结点的指针即可,最后返回头结点,就是旋转之后的链表了。 - -```c -struct ListNode* rotateRight(struct ListNode* head, int k){ - if(head == NULL || k == 0) return head; //如果给进来的链表是空的,或者说k为0,那么就没必要再继续了 - struct ListNode * node = head; - int len = 1; - while (node->next) { //先来算一波链表的长度 - node = node->next; - len++; - } - if(k == len) return head; //如果len和k长度一样,那也没必要继续了 - - node->next = head; //将链表连起来变成循环的,一会再切割 - int index = len - k % len; //计算头结点最终位置 - - node = head; - while (--index) node = node->next; - head = node->next; //找到新的头结点 - node->next = NULL; //切断尾部与头部 - return head; //返回新的头结点 -} -``` - -### (简单)有效的括号 - -本题来自LeetCode:[20. 有效的括号](https://leetcode.cn/problems/valid-parentheses/) - -给定一个只包括 '(',')','{','}','[',']' 的字符串 s ,判断字符串是否有效。 - -有效字符串需满足: - -1. 左括号必须用相同类型的右括号闭合。 -2. 左括号必须以正确的顺序闭合。 - - -示例 1: - -> 输入:s = "()" -> 输出:true - -示例 2: - -> 输入:s = "()[]{}" -> 输出:true - -示例 3: - -> 输入:s = "(]" -> 输出:false - -**示例 4:** - -> 输入:s = "([)]" -> 输出:false - -**示例 5:** - -> 输入:s = "{[]}" -> 输出:true - -题干很明确,就是需要我们去对这些括号完成匹配,如果给定字符串中的括号无法完成一一匹配的话,那么就表示匹配失败。实际上这种问题我们就可以利用前面学习的栈这种数据结构来解决,我们可以将所有括号的左半部分放入栈中,当遇到右半部分时,进行匹配,如果匹配失败,那么就失败,如果匹配成功,那么就消耗一个左半部分,直到括号消耗完毕。 - -```c -#include -#include -#include - -typedef char E; - -struct LNode { - E element; - struct LNode * next; -}; - -typedef struct LNode * Node; - -void initStack(Node head){ - head->next = NULL; -} - -_Bool pushStack(Node head, E element){ - Node node = malloc(sizeof(struct LNode)); - if(node == NULL) return 0; - node->next = head->next; - node->element = element; - head->next = node; - return 1; -} - -_Bool isEmpty(Node head){ - return head->next == NULL; -} - -E popStack(Node head){ - Node top = head->next; - head->next = head->next->next; - E e = top->element; - free(top); - return e; -} - -bool isValid(char * s){ - unsigned long len = strlen(s); - if(len % 2 == 1) return false; //如果长度不是偶数,那么一定不能成功匹配 - struct LNode head; - initStack(&head); - for (int i = 0; i < len; ++i) { - char c = s[i]; - if(c == '(' || c == '[' || c == '{') { - pushStack(&head, c); - }else { - if(isEmpty(&head)) return false; - if(c == ')') { - if(popStack(&head) != '(') return false; - } else if(c == ']') { - if(popStack(&head) != '[') return false; - } else { - if(popStack(&head) != '{') return false; - } - } - } - return isEmpty(&head); -} -``` - -一般遇到括号匹配问题、算式计算问题,都可以使用栈这种数据结构来轻松解决。当然使用C语言太过原始,像Java、C++这些语言一般系统库都会直接提供栈的实现类,所以我们在打比赛时,可以尽量选择这些方便的语言,能节省不少时间。 - -### (简单)第 k 个缺失的正整数 - -本题来自LeetCode:[1539. 第 k 个缺失的正整数](https://leetcode.cn/problems/kth-missing-positive-number/) - -给你一个 严格升序排列 的正整数数组 arr 和一个整数 k 。 - -请你找到这个数组里第 k 个缺失的正整数。 - -示例 1: - -> 输入:arr = [2,3,4,7,11], k = 5 -> 输出:9 -> 解释:缺失的正整数包括 [1,5,6,8,9,10,12,13,...] 。第 5 个缺失的正整数为 9 。 - -示例 2: - -> 输入:arr = [1,2,3,4], k = 2 -> 输出:6 -> 解释:缺失的正整数包括 [5,6,7,...] 。第 2 个缺失的正整数为 6 。 - -实际上这种问题,我们第一个能够想到的就是直接通过遍历挨个寻找,从头开始一个一个找,总能找到第K个吧?我们可以很轻松地得到如下的代码: - -```c -int findKthPositive(int* arr, int arrSize, int k){ - int j = 1, i = 0; //直接从第一个元素开始挨个找 - while (i < arrSize) { - if(arr[i] != j) { - if(--k == 0) return j; //发现不相等时,相当于找到了一个数,k自减,如果自减后为0,那么说明已经找到第K个了,直接返回对应的j - } else{ - i++; //相等的话就继续看下一个 - } - j++; //每一轮j自增,表示下一轮应该按顺序匹配的数 - } - return j + k - 1; //如果遍历完了都还没找到,那就按顺序直接算出下一个 -} -``` - -不过这样的效率并不高,如果这个数组特别长的话,那么我们总不可能还是挨个看吧?这样的遍历查找算法的时间复杂度为$O(n)$,那么有没有更好的算法能够解决这种问题呢? - -既然这个数组是有序的,那么我们不妨直接采用二分搜索的思想,通过使用二分搜索,我们就可以更快速地找到对应的位置,但是有一个问题,我们怎么知道二分搜索找到的数,是不是第N个数呢?实际上也很简单,通过规律我们不难发现,如果某个位置上的数不匹配,那么被跳过的数`k`一定满足: -$$ -k = arr[i] - i - 1 -$$ -所以,我们只需要找到一个大于等于`k`的位置即可,并且要尽可能的接近,在找到之后,再根据公式去寻找即可: - -```c -int findKthPositive(int *arr, int arrSize, int k) { - if (arr[0] > k) return k; - - int l = 0, r = arrSize; - while (l < r) { - int mid = (l + r) / 2; - if (arr[mid] - mid - 1 >= k) { - r = mid; - } else { - l = mid + 1; - } - } - - return k - (arr[l - 1] - (l - 1) - 1) + arr[l - 1]; -} -``` - diff --git a/青空笔记/数据结构笔记/数据结构与算法(三).md b/青空笔记/数据结构笔记/数据结构与算法(三).md deleted file mode 100644 index 30a3737..0000000 --- a/青空笔记/数据结构笔记/数据结构与算法(三).md +++ /dev/null @@ -1,477 +0,0 @@ -![image-20220818205734584](https://s2.loli.net/2022/08/18/xqdTA4wa9QEG5yU.png) - -# 散列表篇 - -在之前,我们已经学习了多种查找数据的方式,比如最简单的,如果数据量不大的情况下,我们可以直接通过顺序查找的方式在集合中搜索我们想要的元素;当数据量较大时,我们可以使用二分搜索来快速找到我们想要的数据,不过需要要求数据按照顺序排列,并且不允许中途对集合进行修改。 - -在学习完树形结构篇之后,我们可以利用二叉查找树来建立一个便于我们查找的树形结构,甚至可以将其优化为平衡二叉树或是红黑树来进一步提升稳定性。在最后我们还了解了B树和B+树,得益于它们的巧妙设计,我们可以以尽可能少的时间快速找到我们需要的元素,大大提升程序的运行效率。 - -这些都能够极大地帮助我们查找数据,而散列表,则是我们查找系列内容的最后一块重要知识。 - -## 散列查找 - -我们之前认识的查找算法,最快可以达到对数阶 $O(logN)$,那么我们能否追求极致,让查找性能突破到常数阶呢?这里就要介绍到我们的**散列**(也可以叫哈希 Hash)它采用直接寻址的方式,在理想情况下,查找的时间复杂度可以达到常数阶 $O(1)$。 - -散列(Hashing)通过散列函数(哈希函数)将要参与检索的数据与散列值(哈希值)关联起来,生成一种便于搜索的数据结构,我们称其为散列表(哈希表),也就是说,现在我们需要将一堆数据保存起来,这些数据会通过哈希函数进行计算,得到与其对应的哈希值,当我们下次需要查找这些数据时,只需要再次计算哈希值就能快速找到对应的元素了: - -![image-20220818214145347](https://s2.loli.net/2022/08/18/Tcj6Spy2Pt5ZIuW.png) - -当然,如果一脸懵逼没关系,我们从哈希函数开始慢慢介绍。 - -### 散列函数 - -散列函数也叫哈希函数,哈希函数可以对一个目标计算出其对应的哈希值,并且,只要是同一个目标,无论计算多少次,得到的哈希值都是一样的结果,不同的目标计算出的结果介乎都不同。哈希函数在现实生活中应用十分广泛,比如很多下载网站都提供下载文件的MD5码校验,可以用来判别文件是否完整,哈希函数多种多样,目前应用最为广泛的是SHA-1和MD5,比如我们在下载IDEA之后,会看到有一个验证文件SHA-256校验和的选项,我们可以点进去看看: - -![image-20220818214908458](https://s2.loli.net/2022/08/18/tD8AjiGwvJkdahE.png) - -点进去之后,得到: - -``` -e54a026da11d05d9bb0172f4ef936ba2366f985b5424e7eecf9e9341804d65bf *ideaIU-2022.2.1.dmg -``` - -这一串由数字和小写字母随意组合的一个字符串,就是安装包文件通过哈希算法计算得到的结果,那么这个东西有什么用呢?我们的网络可能有时候会出现卡顿的情况,导致我们下载的文件可能会出现不完整的情况,因为哈希函数对同一个文件计算得到的结果是一样的,我们可以在本地使用同样的哈希函数去计算下载文件的哈希值,如果与官方一致,那么就说明是同一个文件,如果不一致,那么说明文件在传输过程中出现了损坏。 - -可见,哈希函数在这些地方就显得非常实用,在我们的生活中起了很大的作用,它也可以用于布隆过滤器和负载均衡等场景,这里不多做介绍了。 - -### 散列表 - -前面我们介绍了散列函数,我们知道可以通过散列函数计算一个目标的哈希值,那么这个哈希值计算出来有什么用呢,对我们的程序设计有什么意义呢?我们可以利用哈希值的特性,设计一张全新的表结构,这种表结构是专为哈希设立的,我们称其为哈希表(散列表) - -![image-20220818220944783](https://s2.loli.net/2022/08/18/M2o1vE7hHasN8DP.png) - -我们可以将这些元素保存到哈希表中,而保存的位置则与其对应的哈希值有关,哈希值是通过哈希函数计算得到的,我们只需要将对应元素的关键字(一般是整数)提供给哈希函数就可以进行计算了,一般比较简单的哈希函数就是取模操作,哈希表长度是多少(长度最好是一个素数),模就是多少: - -![image-20220819170355221](https://s2.loli.net/2022/08/19/CAPhlJnQeLjMHfd.png) - -比如现在我们需要插入一个新的元素(关键字为17)到哈希表中: - -![image-20220819171430332](https://s2.loli.net/2022/08/19/ovieRjrzlXhKMC2.png) - -插入的位置为计算出来的哈希值,比如上面是8,那么就在下标位置8插入元素,同样的,我们继续插入27: - -![image-20220819210336314](https://s2.loli.net/2022/08/19/pisuSAIZyf5JE7B.png) - -这样,我们就可以将多种多样的数据保存到哈希表中了,注意保存的数据是无序的,因为我们也不清楚计算完哈希值最后会放到哪个位置。那么如果现在我们想要从哈希表中查找数据呢?比如我们现在需要查找哈希表中是否有14这个元素: - -![image-20220819211656628](https://s2.loli.net/2022/08/19/H1hAvQPjNui2RYt.png) - -同样的,直接去看哈希值对应位置上看看有没有这个元素,如果没有,那么就说明哈希表中没有这个元素。可以看到,哈希表在查找时只需要进行一次哈希函数计算就能直接找到对应元素的存储位置,效率极高。 - -我们可以通过代码来实现一下: - -```c -#define SIZE 9 - -typedef struct Element { //这里用一个Element将值包装一下 - int key; //这里元素设定为int -} * E; - -typedef struct HashTable{ //这里把数组封装为一个哈希表 - E * table; -} * HashTable; - -int hash(int key){ //哈希函数 - return key % SIZE; -} - -void init(HashTable hashTable){ //初始化函数 - hashTable->table = malloc(sizeof(struct Element) * SIZE); - for (int i = 0; i < SIZE; ++i) - hashTable->table[i] = NULL; -} - -void insert(HashTable hashTable, E element){ //插入操作,为了方便就不考虑装满的情况了 - int hashCode = hash(element->key); //首先计算元素的哈希值 - hashTable->table[hashCode] = element; //对号入座 -} - -_Bool find(HashTable hashTable, int key){ - int hashCode = hash(key); //首先计算元素的哈希值 - if(!hashTable->table[hashCode]) return 0; //如果为NULL那就说明没有 - return hashTable->table[hashCode]->key == key; //如果有,直接看是不是就完事 -} - -E create(int key){ //创建一个新的元素 - E e = malloc(sizeof(struct Element)); - e->key = key; - return e; -} - -int main() { - struct HashTable hashTable; - init(&hashTable); - insert(&hashTable, create(10)); - insert(&hashTable, create(7)); - insert(&hashTable, create(13)); - insert(&hashTable, create(29)); - - printf("%d\n", find(&hashTable, 1)); - printf("%d\n", find(&hashTable, 13)); -} -``` - -这样,我们就实现了一个简单的哈希表和哈希函数,通过哈希表,我们可以将数据的查找时间复杂度提升到常数阶。 - -## 哈希冲突 - -前面我介绍了哈希函数,通过哈希函数计算得到一个目标的哈希值,但是在某些情况下,哈希值可能会出现相同的情况: - -![image-20220819215004653](https://s2.loli.net/2022/08/19/XqpZd1YP5ulEJRy.png) - -比如现在同时插入14和23这两个元素,他们两个计算出来的哈希值是一样的,都需要在5号下标位置插入,这时就出现了打架的情况,那么到底是把哪一个放进去呢?这种情况,我们称为**哈希碰撞**(哈希冲突) - -这种问题是很严重的,因为哈希函数的设计不同,难免会出现这种情况,这种情况是不可避免的,我们只能通过使用更加高级的哈希函数来尽可能避免这种情况,但是无法完全避免。当然,如果要完全解决这种问题,我们还需要去寻找更好的方法。 - -### 线性探测法 - -既然有可能出现哈希值重复的情况,那么我们可以选择退让,不去进行争抢(忍一时风平浪静,退一步海阔天空)我们可以去找找哈希表中相邻的位置上有没有为空的,只要哈希表没装满,那么我们肯定是可以找到位置装下这个元素的,这种类型的解决方案我们统称为**线性探测法**,开放定址法包含,线性探测法、平方探测法、双散列法等,这里我们以线性探测法为例。 - -既然第一次发生了哈希冲突,那么我们就继续去找下一个空位: -$$ -h_i(key) = (h(key) + d_i)\space \% \space TableSize -$$ -其中 $d_i$ 是随着哈希冲突次数增加随之增加的量,比如上面出现了一次哈希冲突,那么我就将其变成`1`表示发生了一次哈希冲突,然后我们可以继续去寻找下一个位置: - -![image-20220820112822005](https://s2.loli.net/2022/08/20/p5Qdni31eqFgzZ7.png) - -出现哈希冲突时,$d_i$自增,继续寻找下一个空位: - -![image-20220820113020326](https://s2.loli.net/2022/08/20/Ay6zkgivEFLthM8.png) - -再次计算哈希值,成功得到对应的位置,注意 $d_i$ 默认为0,这样我们就可以解决冲突的情况了。 - -我们来通过代码实际使用一下,这里需要调整一下插入和查找操作的逻辑: - -```c -void insert(HashTable hashTable, E element){ //插入操作,注意没考虑满的情况,各位小伙伴可以自己实现一下 - int hashCode = hash(element->key), count = 0; - while (hashTable->table[hashCode]) { //如果发现哈希冲突,那么需要继续寻找 - hashCode = hash(element->key + ++count); - } - hashTable->table[hashCode] = element; //对号入座 -} - -_Bool find(HashTable hashTable, int key){ - int hashCode = hash(key), count = 0; //首先计算元素的哈希值 - const int startIndex = hashCode; //记录一下起始位置,要是转一圈回来了得停 - do { - if(hashTable->table[hashCode]->key == key) return 1; //如果找到就返回1 - hashCode = hash(key + ++count); - } while (startIndex != hashCode && hashTable->table[hashCode]); //没找到继续找 - return 0; -} -``` - -这样当出现哈希冲突时,会自动寻找补位插入: - -```c -int main() { - struct HashTable hashTable; - init(&hashTable); - for (int i = 0; i < 9; ++i) { - insert(&hashTable, create(i * 9)); - } - - for (int i = 0; i < 9; ++i) { - printf("%d ", hashTable.table[i]->key); - } -} -``` - -当然,如果采用这种方案删除会比较麻烦,因为有些元素可能是通过线性探测补到其他位置上的,如果删除元素,那么很有可能会影响到前面的查找操作: - -![image-20220820211324957](https://s2.loli.net/2022/08/20/PJIVAUnhT6OwB9d.png) - -此时删除关键字为45的元素,会出现截断的情况,当下次查找时,会出现严重问题: - -![image-20220820214945139](https://s2.loli.net/2022/08/20/am6WHpejxtyU842.png) - -可以看到,删除一个元素可能会导致原有的结构意外截断,无法正确找到对应的元素,所以,我们在删除元素时,为了防止出现这种截断的情况,我们需要对这个位置进行标记,表示之前有过元素,但是被删除了,当我们在查找时,如果发现曾经有过元素,依然需要继续向后寻找: - -![image-20220820215613368](https://s2.loli.net/2022/08/20/hIBUbKvDjAfYruL.png) - -代码实现有点麻烦,这里就不编写代码了。 - -当然除了直接向后进行探测之外,我们也可以采用**二次探测再散列法**处理哈希冲突,因为有些时候可能刚好后面没有空位了,但是前面有,如果按照之前的方法,我们得转一圈回来才能找到对应的位置,实在是有点浪费时间,所以说我们可以左右开弓,同时向两个方向去寻找。 - -它的查找增量序列为:$1^2$、$-1^2$、$2^2$、$-2^2$、...、$q^2$、$-q^2$,其中$q <= \lfloor {TableSize\div2} \rfloor$,比如现在我们要向下面的哈希表中插入数据,现在插入关键字为24的元素,发现冲突了: - -![image-20220821214600725](https://s2.loli.net/2022/08/21/CTEFJVNmf47B3yq.png) - -那么此时就需要进行处理了,这里我们采用上面的方式,先去寻找 $1^2$ 位置: - -![image-20220821214751809](https://s2.loli.net/2022/08/21/QmiDsrnZjX8YUb6.png) - -我们接着来插入: - -![image-20220821215445041](https://s2.loli.net/2022/08/21/Wj9wYLPovF6pAOs.png) - -实际上我们发现和之前是一样的,只要冲突就一直往下找就完事,只不过现在是左右横跳着找,这样可以进一步提升利用率。 - -### 链地址法 - -实际上常见的哈希冲突解决方案是**链地址法**,当出现哈希冲突时,我们依然将其保存在对应的位置上,我们可以将其连接为一个链表的形式: - -![image-20220820220237535](/Users/nagocoler/Library/Application Support/typora-user-images/image-20220820220237535.png) - -当表中元素变多时,差不多就变成了这样,我们一般将其横过来看: - -![image-20220820221104298](/Users/nagocoler/Library/Application Support/typora-user-images/image-20220820221104298.png) - -通过结合链表的形式,哈希冲突问题就可以得到解决了,但是同时也会出现一定的查找开销,因为现在有了链表,我们得挨个往后看才能找到,当链表变得很长时,查找效率也会变低,此时我们可以考虑结合其他的数据结构来提升效率。比如当链表长度达到8时,自动转换为一棵平衡二叉树或是红黑树,这样就可以在一定程度上缓解查找的压力了。 - -我们来编写代码尝试一下: - -```c -#define SIZE 9 - -typedef struct ListNode { //结点定义 - int key; - struct ListNode * next; -} * Node; - -typedef struct HashTable{ //哈希表 - struct ListNode * table; //这个数组专门保存头结点 -} * HashTable; - -void init(HashTable hashTable){ - hashTable->table = malloc(sizeof(struct ListNode) * SIZE); - for (int i = 0; i < SIZE; ++i) { - hashTable->table[i].key = -1; //将头结点key置为-1,next指向NULL - hashTable->table[i].next = NULL; - } -} - -int main(){ - struct HashTable table; //创建哈希表 - init(&table); -} -``` - -接着是编写对应的插入操作,插入后直接往链表后面丢就完事了: - -```c -int hash(int key){ //哈希函数 - return key % SIZE; -} - -Node createNode(int key){ //创建结点专用函数 - Node node = malloc(sizeof(struct ListNode)); - node->key = key; - node->next = NULL; - return node; -} - -void insert(HashTable hashTable, int key){ - int hashCode = hash(key); - Node head = hashTable->table + hashCode; //先计算哈希值,找到位置后直接往链表后面插入结点就完事了 - while (head->next) head = head->next; - head->next = createNode(key); //插入新的结点 -} -``` - -同样的,查找的话也是直接找到对应位置,看看链表里面有没有就行: - -```c -_Bool find(HashTable hashTable, int key){ - int hashCode = hash(key); - Node head = hashTable->table + hashCode; - while (head->next && head->key != key) //直到最后或是找到为止 - head = head->next; - return head->key == key; //直接返回是否找到 -} -``` - -我们来测试一下吧: - -```c -int main(){ - struct HashTable table; - init(&table); - - insert(&table, 10); - insert(&table, 19); - insert(&table, 20); - - printf("%d\n", find(&table, 20)); - printf("%d\n", find(&table, 17)); - printf("%d\n", find(&table, 19)); -} -``` - -实际上这种方案代码写起来也会更简单,使用也更方便一些。 - -**散列表习题:** - -1. 下面关于哈希查找的说法,正确的是( ) - - A 哈希函数构造的越复杂越好,因为这样随机性好,冲突小 - - B 除留余数法是所有哈希函数中最好的 - - C 不存在特别好与坏的哈希函数,要视情况而定 - - D 越简单的哈希函数越容易出现冲突,是最坏的 - - *首先,衡量哈希函数好坏并没有一个确切的标准,而是需要根据具体情况而定,并不一定复杂的哈希函数就好,因为会带来时间上的损失。其实我们的生活中很多东西都像这样,没有好坏之分,只有适不适合的说法,所以说选择C选项* - -2. 设有一组记录的关键字为{19,14,23,1,68,20,84,27,55,11,10,79},用链地址法构造散列表,散列函数为H(key)=key MOD 13,散列地址为1的链中有( )个记录。 - - A 1 B 2 C 3 D 4 - - *这种咱们得画图才知道了,答案是D* - -3. 设哈希表长为14,哈希函数是H(key)=key%11,表中已有数据的关键字为15,38,61,84共四个,现要将关键字为49的元素加到表中,用二次探测再散列解决冲突,则放入的位置是( ) - - A 8 B 3 C 5 D 9 - - *咱们先把这个表给画出来吧,答案是D* - -4. 选取哈希函数 H(key)=(key x 3)%11 用线性探测散列法和二次探测再散列法分别处理冲突。试在0~10的散列地址空间中,对关键字序列(22,41,53,46,30,13,1,67)构建哈希表,并求等概率情况下查找成功的平均查找长度。 - - *其中平均查找长度(ASL)就是表中每一个元素需要查找次数之和的平均值,我们注意在插入元素时顺便记录计算次数即可,如果是链地址法,那么直接看层数就行,ASL =(第一层结点数量+第二层结点数量+第三层结点数量)/ 非头结点总数* - - -## 算法实战 - -### (简单)两数之和 - -本题来自LeetCode:[1.两数之和](https://leetcode.cn/problems/two-sum/)(整个力扣的第一题) - -给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。 - -你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。 - -你可以按任意顺序返回答案。 - - 示例 1: - -> 输入:nums = [2,7,11,15], target = 9 -> 输出:[0,1] -> 解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。 - -示例 2: - -> 输入:nums = [3,2,4], target = 6 -> 输出:[1,2] - -示例 3: - -> 输入:nums = [3,3], target = 6 -> 输出:[0,1] - -这道题很简单,实际上使用暴力枚举是可以完成的,我们只需要让每个数去寻找一个与其匹配的数即可,所以说直接循环就完事: - -```c -int * result(int i, int j, int * returnSize){ - *returnSize = 2; - int * result = malloc(sizeof(int) * 2); - result[0] = i; - result[1] = j; - return result; -} - -int* twoSum(int* nums, int numsSize, int target, int* returnSize){ - for (int i = 0; i < numsSize; ++i) { - for (int j = 0; j < numsSize; ++j) { - if(j == i) continue; - if(nums[i] + nums[j] == target) - return result(i, j, returnSize); //找到匹配就直接返回完事 - } - } - return NULL; //无视即可,因为不可能 -} -``` - -但是这样效率实在是太低了,可以看到我们的程序运行时间都好几百毫秒了,能不能优化一下呢?我们正好学习了散列表,是否可以利用一下散列表来帮助我们完成? - -因为每当我们遍历一个数时,实际上就是去寻找与其匹配的数是否存在,我们可以每遍历一个数都将其存放到散列表中,当下次遇到与其相匹配的数时,只要能够从散列表中找到这个数,那么就可以直接完成匹配了,这样就只需要遍历一次即可完成。比如: - -> [2,7,11,15] ,targert = 9 -> -> 第一次先将2放入散列表,接着往后看7,现在目标值时9,那么只需要去寻找 9 - 7 这个数,看看散列表中有没有即可,此时散列表中正好有2,所以说直接返回即可。 - -我们来尝试编写一下: - -```c -#define SIZE 128 - -typedef int K; -typedef int V; - -typedef struct LNode { //结点定义需要稍微修改一下,因为除了存关键字还需要存一下下标 - K key; - V value; - struct LNode * next; -} * Node; - -typedef struct HashTable{ //哈希表 - struct LNode * table; //这个数组专门保存头结点 -} * HashTable; - -void init(HashTable hashTable){ - hashTable->table = malloc(sizeof(struct LNode) * SIZE); - for (int i = 0; i < SIZE; ++i) { - hashTable->table[i].key = -1; //将头结点key置为-1,value也变成-1,next指向NULL - hashTable->table[i].value = -1; - hashTable->table[i].next = NULL; - } -} - -int hash(unsigned int key){ //因为哈希表用的数组,要是遇到负数的key,肯定不行,咱先给它把符号扬了再算 - return key % SIZE; -} - -Node create(K key, V value){ //创建结点,跟之前差不多 - Node node = malloc(sizeof(struct LNode)); - node->key = key; - node->value = value; - node->next = NULL; - return node; -} - -void insert(HashTable hashTable, K key, V value){ - int hashCode = hash(key); - Node head = hashTable->table + hashCode; - while (head->next) head = head->next; - head->next = create(key, value); //这里同时保存关键字和对应的下标 -} - -Node find(HashTable hashTable, K key){ - int hashCode = hash(key); - Node head = hashTable->table + hashCode; //直接定位到对应位置 - while (head->next && head->next->key != key) //直接看有没有下一个结点,并且下一个结点不是key - head = head->next; //继续往后找 - return head->next; //出来之后要么到头了下一个是NULL,要么就是找到了,直接返回 -} -``` - -哈希表编写完成后,我们就可以使用了: - -```c -int * result(int i, int j, int * returnSize){ //跟上面一样 - *returnSize = 2; - int * result = malloc(sizeof(int) * 2); - result[0] = i; - result[1] = j; - return result; -} - -int* twoSum(int* nums, int numsSize, int target, int* returnSize){ - struct HashTable table; //初始化哈希表 - init(&table); - for (int i = 0; i < numsSize; ++i) { //挨个遍历 - Node node = find(&table, target - nums[i]); //直接去哈希表里面寻找匹配的,如果有直接结束,没有就丢把当前的key丢进哈希表,之后如果遇到与其匹配的另一半,那么就直接成功了 - if(node != NULL) return result(i, node->value, returnSize); - insert(&table, nums[i], i); - } - return NULL; //无视就好 -} -``` - -我们再次提交代码,时间直接来到了个位数: - -![image-20220821122010425](https://s2.loli.net/2022/08/21/pGF2hZo5ArbLyfB.png) - -采用哈希表,就是一种空间换时间的策略,在大多数情况下,我们也更推荐使用这种方案。 diff --git a/青空笔记/数据结构笔记/数据结构与算法(二).md b/青空笔记/数据结构笔记/数据结构与算法(二).md deleted file mode 100644 index 30eecb5..0000000 --- a/青空笔记/数据结构笔记/数据结构与算法(二).md +++ /dev/null @@ -1,2520 +0,0 @@ -![image-20220801205609763](https://s2.loli.net/2022/08/01/QjFzq4LHObBxhKl.png) - -# 树形结构篇 - -前面我们学习了线性相关的数据结构,了解了顺序表和链表两种类型,我们接着来看树形结构。这一章会更加考验各位小伙伴的数学功底以及逻辑思维,难度会更大一些。 - -## 树与森林 - -树是一种全新的数据结构,它就像一棵树的树枝一样,不断延伸。 - -![image-20220808151202634](https://s2.loli.net/2022/08/08/NajFZzXHxUCDQBW.png) - -### 树结构介绍 - -一棵树就像下面这样连接: - -![image-20220801210920230](https://s2.loli.net/2022/08/01/aoBjrR5bPqWzCel.png) - -可以看到,现在一个结点下面可能会连接多个节点,并不断延伸,就像树枝一样,每个结点都有可能是一个分支点,延伸出多个分支,从位于最上方的结点开始不断向下,而这种数据结构,我们就称为**树**(Tree)注意分支只能向后单独延伸,之后就分道扬镳了,**不能与其他分支上的结点相交!** - -* 我们一般称位于最上方的结点为树的**根结点**(Root)因为整棵树正是从这里开始延伸出去的。 -* 每个结点连接的子结点数目(分支的数目),我们称为结点的**度**(Degree),而各个结点度的最大值称为树的度。 -* 每个结点延伸下去的下一个结点都可以称为一棵**子树**(SubTree)比如结点`B`及其之后延伸的所有分支合在一起,就是一棵`A`的子树。 -* 每个**结点的层次**(Level)按照从上往下的顺序,树的根结点为`1`,每向下一层`+1`,比如`G`的层次就是`3`,整棵树中所有结点的最大层次,就是这颗**树的深度**(Depth),比如上面这棵树的深度为4,因为最大层次就是4。 - -由于整棵树错综复杂,所以说我们需要先规定一下结点之间的称呼,就像族谱那样: - -* 与当前结点直接向下相连的结点,我们称为**子结点**(Child),比如`B、C、D`结点,都是`A`的子结点,就像族谱中的父子关系一样,下一代一定是子女,相反的,那么`A`就是`B、C、D`的**父结点**(Parent),也可以叫双亲结点。 -* 如果某个节点没有任何的子结点(结点度为0时)那么我们称这个结点为**叶子结点**(因为已经到头了,后面没有分支了,这时就该树枝上长叶子了那样)比如`K、L、F、G、M、I、J`结点,都是叶子结点。 -* 如果两个结点的父结点是同一个,那么称这两个节点为**兄弟结点**(Sibling)比如`B`和`C`就是兄弟结点,因为都是`A`的孩子。 -* 从根结点开始一直到某个结点的整条路径的所有结点,都是这个结点的**祖先结点**(Ancestor)比如`L`的祖先结点就是`A、B、E` - -那么在了解了树的相关称呼之后,相信各位就应该对树有了一定的了解,虽然概念比较多,但是还请各位一定记住,不然后面就容易听懵。 - -### 森林 - -森林其实很好理解,一片森林肯定是是由很多棵树构成的,比如下面的三棵树: - -![image-20220801222928422](https://s2.loli.net/2022/08/01/VnblyMgQXkC6cBu.png) - -它们共同组成了一片森林,因此,m(m≥0)棵树的集合我们称为**森林**(Forest) - -*** - -## 二叉树 - -前面我们给大家介绍了树的概念,而我们本章需要着重讨论的是**二叉树**(Binary Tree)它是一种特殊的树,它的度最大只能为`2`,所以我们称其为二叉树,一棵二叉树大概长这样: - -![image-20220801224008266](https://s2.loli.net/2022/08/01/QGLfnYWFby37deP.png) - -并且二叉树任何结点的子树是有左右之分的,不能颠倒顺序,比如A结点左边的子树,称为左子树,右边的子树称为右子树。 - -二叉树有5种基本形态,分别是: - -![image-20220801224513856](https://s2.loli.net/2022/08/01/8ncvzo6aLem14ju.png) - -当然,对于某些二叉树我们有特别的称呼,比如,在一棵二叉树中,所有分支结点都存在左子树和右子树,且叶子结点都在同一层: - -![image-20220801231216578](https://s2.loli.net/2022/08/01/btfjlJhDuWrSXYi.png) - -这样的二叉树我们称为**满二叉树**,可以看到整棵树都是很饱满的,没有出现任何度为1的结点,当然,还有一种特殊情况: - -![image-20220801224008266](https://s2.loli.net/2022/08/01/QGLfnYWFby37deP.png) - -可以看到只有最后一层有空缺,并且所有的叶子结点是按照从左往右的顺序排列的,这样的二叉树我们一般称其为**完全二叉树**,所以,一棵满二叉树,一定是一棵完全二叉树。 - -### 树和森林的转换 - -二叉树和树、森林之间是可以相互转换的。 - -我们可以使用下面的规律将一棵普通的树转换为一棵二叉树: - -1. 最左边孩子结点 -> 左子树结点(左孩子) -2. 兄弟结点 -> 右子树结点(右孩子) - -我们以下面的这棵树为例: - -![image-20220806101322807](https://s2.loli.net/2022/08/06/y51pTzhrQV3GPCJ.png) - -我们优先从左边开始看,B、F、G都是A的子结点,根据上面的规律,我们将B作为左子树: - -![image-20220806101841459](https://s2.loli.net/2022/08/06/g4XfmiQHaOy6JhG.png) - -接着继续从左往右看,由于F是B的兄弟结点,那么根据规律,F作为B的右子树: - -![image-20220806102023764](https://s2.loli.net/2022/08/06/6wqO4iErjQpyKzP.png) - -接着是G,G是F的兄弟结点,那么G继续作为F的右子树: - -![image-20220806102123476](https://s2.loli.net/2022/08/06/DfBsxVHlSotn6I3.png) - -我们接着来看第三排,依然是从左往右,C是B的子节点,所以C作为B的左子树: - -![image-20220806102501769](https://s2.loli.net/2022/08/06/93zFJGyx2SBLHC4.png) - -接着,D是C的兄弟节点,那么D就作为C的右子树了: - -![image-20220806102619705](https://s2.loli.net/2022/08/06/YO5zf2TVHqBdnX6.png) - -此时还有一个H结点,它是G的子结点,所以直接作为G的左子树: - -![image-20220806102802036](https://s2.loli.net/2022/08/06/oHcAM6d2SFrveaE.png) - -现在只剩下最后一排了,E是D的子结点,K是H的子结点,所以最后就像这样了: - -![image-20220806102932517](https://s2.loli.net/2022/08/06/6JxYP2CXSyZdGa4.png) - -按照规律,我们就将一棵树转换为了二叉树。当然还有一种更简单的方法,我们可以直接将所有的兄弟结点连起来(橙色横线): - -![image-20220807231603707](https://s2.loli.net/2022/08/07/OSZ71J6CVEzeNiW.png) - -接着擦掉所有结点除了最左边结点以外的连线: - -![image-20220807231704465](https://s2.loli.net/2022/08/07/y62Z3UlaWdemI7v.png) - -所有的黑色连线偏向左边,橙色连线偏向右边: - -![image-20220807231922091](https://s2.loli.net/2022/08/07/yzA2uLqhYDnbZcJ.png) - -效果是一样的,这两种方式都可以,你觉得哪一种简单就使用哪一种就行了。我们会发现,无论一棵树长成啥样,转换为二叉树后,**根节点一定没有右子树**。 - -**思考:**那二叉树咋变回普通的树呢?实际上我们只需要反推回去就行了。 - -那么森林呢,森林如何转换为一棵二叉树呢?其实很简单: - -![image-20220808113135783](https://s2.loli.net/2022/08/08/QCIaYTcEv2NO47G.png) - -首先我们还是按照二叉树转换为树的规则,将森林中所有树转换为二叉树,接着我们只需要依次连接即可: - -![image-20220808113251636](https://s2.loli.net/2022/08/08/O3xnhv85WLPzJpq.png) - -注意连接每一棵树的时候,一律从根结点的右边开始,不断向右连接。 - -我们发现,相比树转换为二叉树,森林转换为二叉树之后,根节点就存在右子树了,右子树连接的都是森林中其他的树。 - -**思考:**现在有一棵二叉树,我们想要转回去,我们怎么知道到底是将其转换为森林还是转换为树呢? - -### 二叉树的性质 - -由于二叉树结构特殊,我们可以总结出以下的五个性质: - -* **性质一:**对于一棵二叉树,第`i`层的最大结点数量为 $2^{i-1}$ 个,比如二叉树的第一层只有一个根结点,也就是 $2^0 = 1$ ,而二叉树的第三层可以有 $2^2 = 4$ 个结点。 - - - -* **性质二:**对于一棵深度为`k`的二叉树,可以具有的最大结点数量为: - $$ - n = 2^0 + 2^1 + 2^2 + ... + 2^{k-1} - $$ - 我们发现,实际上每一层的结点数量,组成了一个等比数列,公比`q`为`2`,结合等比数列求和公式,我们可以将其简化为: - $$ - S_n = \frac {a_1 \times (1 - q^n)} {1 - q} = \frac {1 \times (1 - 2^k)} {1 - 2} = - (1 - 2^k) = 2^k - 1 - $$ - 所以一棵深度为`k`的二叉树最大结点数量为 $n = 2^k - 1$,顺便得出,结点的边数为 $E = n - 1$。 - - - -* **性质三:**假设一棵二叉树中度为0、1、2的结点数量分别为$n_0$、$n_1$、$n_2$,由于一棵二叉树中只有这三种类型的结点,那么可以直接得到结点总数: - $$ - n = n_0 + n_1 + n_2 - $$ - 我们不妨换一个思路,我们从二叉树的边数上考虑,因为每个结点有且仅有一条边与其父结点相连,那么边数之和就可以表示为: - $$ - E = n_1 + 2n_2 - $$ - 度为1的结点有一条边,度为2的结点有两条边,度为0的结点没有,加在一起就是整棵二叉树的边数之和,结合我们在**性质二**中推导的结果,可以得到另一种计算结点总数的方式: - $$ - E = n - 1 = n_1 + 2n_2 - $$ - - $$ - n = n_1 + 2n_2 + 1 - $$ - - 再结合我们第一个公式: - $$ - n = n_0 + n_1 + n_2 = n_1 + 2n_2 + 1 - $$ - 综上,对于任何一棵二叉树,如果其叶子结点个数为 $n_0$ ,度为2的结点个数为 $n_2$ ,那么两者满足以下公式: - $$ - n_0 = n_2 + 1 - $$ - *(性质三的推导过程比较复杂,如果觉得麻烦推荐直接记忆)* - - - -* **性质四:**完全二叉树除了最后一层有空缺外,其他层数都是饱满的,假设这棵二叉树为满二叉树,那么根据我们前面得到的性质,假设层数为`k`,那么结点数量为:$n = 2^k - 1$ ,根据完全二叉树的性质,最后一层可以满可以不满,那么一棵完全二叉树结点数`n`满足: - $$ - 2^{k-1} - 1 < n <= 2^k - 1 - $$ - 因为`n`肯定是一个整数,那么可以写为: - $$ - 2^{k - 1} <= n <= 2^k - 1 - $$ - 现在我们只看左边的不等式,我们对不等式两边同时取对数,得到: - $$ - k - 1 <= log_2n - $$ - 综上所述,一棵具有`n`个结点的完全二叉树深度为 $k = \lfloor log_2n \rfloor + 1$ 。 - - *(性质四的推导过程比较复杂,如果觉得麻烦推荐直接记忆)* - - - -* **性质五:**一颗有`n`个结点的完全二叉树,由性质四得到深度为 $k = \lfloor log_2n \rfloor + 1$ 现在对于任意一个结点`i`,结点的顺序为从上往下,从左往右: - - * 对于一个拥有左右孩子的结点来说,其左孩子为`2i`,右孩子为`2i + 1`。 - * 如果`i = 1`,那么此结点为二叉树的根结点,如果`i > 1`,那么其父结点就是 $\lfloor i/2 \rfloor$,比如第3个结点的父结点为第1个节点,也就是根结点。 - * 如果`2i > n`,则结点`i`没有左孩子,比如下面图中的二叉树,n为5,假设此时`i = 3`,那么`2i = 6 > n = 5` 说明第三个结点没有左子树。 - * 如果`2i + 1 > n`,则结点`i`没有右孩子。 - -![image-20220805231744693](https://s2.loli.net/2022/08/05/uan6A3ZRLykt289.png) - -以上五条二叉树的性质一般是笔试重点内容,还请务必牢记,如果觉得推导过程比较麻烦,推荐直接记忆结论。 - -**二叉树练习题:** - -1. **由三个结点可以构造出多少种不同的二叉树?** - - *这个问题我们可以直接手画得到结果,一共是五种,当然,如果要求N个结点的话,可以利用动态规划求解,如果这道题是求N个结点可以构造多少二叉树,我们可以分析一下:* - - * 假设现在只有一个结点或者没有结点,那么只有一种,$h(0) = h(1) = 1$ - * 假设现在有两个结点,那么其中一个拿来做根结点,剩下这一个可以左边可以右边,要么左边零个结点右边一个结点,要么左边一个结点右边零个结点,所以说 $h(2) = h(1) × h(0) + h(0) × h(1) = 2$ - * 假设现在有三个结点,那么依然是其中一个拿来做根节点,剩下的两个结点情况就多了,要么两个都在左边,两个都在右边,或者一边一个,所以说 $h(3) = h(2) × h(0) + h(1) × h(1) + h(0) × h(2)$ - - *我们发现,它是非常有规律的,N每+1,项数多一项,所以我们只需要按照规律把所有情况的结果相加就行了,我们按照上面推导的结果,编写代码:* - - ```c - int main(){ - int size; - scanf("%d", &size); //读取需要求的N - int dp[size + 1]; - dp[0] = dp[1] = 1; //没有结点或是只有一个结点直接得到1 - for (int i = 2; i <= size; ++i) { - dp[i] = 0; //一开始先等于0再说 - for (int j = 0; j < i; ++j) { //内层循环是为了计算所有情况,比如i等于3,那么就从j = 0开始,计算dp[0]和dp[2]的结果,再计算dp[1]和dp[1]... - dp[i] += dp[j] * dp[i - j - 1]; - } - } - printf("%d", dp[size]); //最后计算的结果就是N个结点构造的二叉树数量了 - } - ``` - - ![image-20220808121124094](https://s2.loli.net/2022/08/08/DIHPQcxgbVXLaYK.png) - - *成功得到结果,当然,实际上我们根据这个规律,还可以将其进一步简化,求出的结果序列为:1, 1, 2, 5, 14, 42, 132...,这种类型的数列我们称为**卡特兰数**,以中国蒙古族数学家明安图 (1692-1763)和比利时的数学家欧仁·查理·卡塔兰 (1814–1894)的名字来命名,它的通项公式为:* - $$ - C_n = \frac {1} {n + 1}C^n_{2n} = \frac {1} {n + 1} \times \frac {(2n)!} {n!\times(2n - n)!} = \frac {(2n)!} {n!\times (n + 1)!} - $$ - *所以说不需要动态规划了,直接一个算式解决问题:* - - ```c - int factorial(int n){ - int res = 1; - for (int i = 2; i <= n; ++i) res *= i; - return res; - } - - int main(){ - int n; - scanf("%d", &n); - printf("%d", factorial(2*n) / (factorial(n) * factorial(n + 1))); - } - ``` - - *只不过这里用的是int,运算过程中如果数字太大的话就没办法了* - -2. **一棵完全二叉树有1001个结点,其中叶子结点的个数为?** - - *既然是完全二叉树,那么最下面这一排肯定是按顺序排的,并且上面各层应该是排满了的,那么我们先求出层数,根据性质四:* - $$ - k = \lfloor log_2n \rfloor + 1 = 9 + 1 = 10 - $$ - *所以此二叉树的层数为10,也就是说上面9层都是满满当当的,最后一层不满,那么根据性质二,我们求出前9层的结点数:* - $$ - n = 2^k - 1 = 511 - $$ - *那么剩下的结点就都是第十层的了,得到第十层所有叶子结点数量 $ = 1001 - 511 = 490$,因为第十层并不满,剩下的叶子第九层也有,所以最后我们还需要求出第九层的叶子结点数量,先计算第九层的所有结点数量:* - $$ - n = 2^{i - 1}=256 - $$ - *接着我们需要去掉那些第九层度为一和度为二的结点,其实只需要让第十层都叶子结点除以2就行了:* - $$ - n = (490 + 1) / 2 = 245 - $$ - *注意在除的时候+1,因为有可能会出现一个度为1的结点,此时也需要剔除,所以说+1变成偶数这样才可以正确得到结果。最后剔除这些结点,得到最终结果:* - $$ - n_0 = 256 - 245 + 490 = 501 - $$ - *所以这道题的答案为501。* - -3. **深度为h的满m叉树的第k层有多少个结点?** - - *这道题只是看着复杂,但是实际上我们把之前推导都公式带进来就行了。但是注意,难点在于,这道题给的是满m叉树,而不是满二叉树,满二叉树根据性质一我们已经知道:* - $$ - n = 2^{i-1} - $$ - 那m叉树呢?实际上也是同理的,我们以三叉树为例,每向下一层,就划分三个孩子结点出来: - - ![image-20220808131305843](https://s2.loli.net/2022/08/08/XvH4At8Q93nkFIR.png) - - 每一层的最大结点数依次为:1、3、9、27.... - - 我们发现,实际上每一层的最大结点数,正好是3的次方,所以说无论多少叉树,实际上变化的就是底数而已,所以说深度为h(h在这里没卵用,障眼法罢了)的满m叉树第k层的结点数: - $$ - n = m^{k-1} - $$ - -4. **一棵有1025个结点的二叉树的层数k的取值范围是?** - - *这个问题比较简单,层数的最小值实际上就是为完全二叉树的情况,层数的最大值实际上就是连成一根线的情况,结点数就是层数,所以说根据性质四得到最小深度为11,最大深度就直接1025了,k的范围是11 - 1025* - -5. **将一棵树转换为二叉树时,根节点的右边连接的是?** - - *根据我们前面总结得到的性质,树转换为二叉树之后,根节点一定没有右子树,所以为空* - -### 二叉树的构建 - -前面我们介绍了二叉树的几个重要性质,那么现在我们就来尝试在程序中表示和使用一棵二叉树。 - -二叉树的存储形式也可以使用我们前面的两种方式,一种是使用数组进行存放,还有一种就是使用链式结构,只不过之前链式结构需要强化一下才可以表示为二叉树。 - -首先我们来看数组形式的表示方式,利用前面所推导的性质五,我们可以按照以下顺序进行存放: - -![image-20220805231744693](https://s2.loli.net/2022/08/05/uan6A3ZRLykt289.png) - -这颗二叉树的顺序存储: - -![image-20220806110546789](https://s2.loli.net/2022/08/06/jTtvWahxI9VUKuG.png) - -从左往右,编号`i`从1开始,比如现在我们需要获取A的右孩子,那么就需要根据性质五进行计算,因为右孩子为`2i + 1`,所以A的右边孩子的编号就是3,也就是结点C。 - -这种表示形式使用起来并不方便,而且存在大量的计算,所以说我们只做了解即可,我们的重点是下面的链式存储方式。 - -我们在前面使用链表的时候,每个结点不仅存放对应的数据,而且会存放一个指向下一个结点的指针: - -![image-20220723171648380](https://s2.loli.net/2022/07/23/ruemiRQplVy7q9s.png) - -而二叉树也可以使用这样的链式存储形式,只不过现在一个结点需要存放一个指向左子树的指针和一个指向右子树的指针了: - -![image-20220806111610082](https://s2.loli.net/2022/08/06/H9MqkghmAjFJnuO.png) - -通过这种方式,我们就可以通过连接不同的结点形成一颗二叉树了,这样也更便于我们去理解它,我们首先定义一个结构体: - -```c -typedef char E; - -struct TreeNode { - E element; //存放元素 - struct TreeNode * left; //指向左子树的指针 - struct TreeNode * right; //指向右子树的指针 -}; - -typedef struct TreeNode * Node; -``` - -比如我们现在想要构建一颗像这样的二叉树: - -![image-20220805231744693](https://s2.loli.net/2022/08/05/uan6A3ZRLykt289.png) - -首先我们需要创建好这几个结点: - -```c -int main(){ - Node a = malloc(sizeof(struct TreeNode)); //依次创建好这五个结点 - Node b = malloc(sizeof(struct TreeNode)); - Node c = malloc(sizeof(struct TreeNode)); - Node d = malloc(sizeof(struct TreeNode)); - Node e = malloc(sizeof(struct TreeNode)); - a->element = 'A'; - b->element = 'B'; - c->element = 'C'; - d->element = 'D'; - e->element = 'E'; -} -``` - -接着我们从最上面开始,挨着进行连接,首先是A这个结点: - -```c -int main(){ - ... - - a->left = b; //A的左孩子是B - a->right = c; //A的右孩子是C -} -``` - -然后是B这个结点: - -```c -int main(){ - ... - - b->left = d; //B的左孩子是D - b->right = e; //B的右孩子是E - - //别忘了把其他的结点改为NULL - ... -} -``` - -这样的话,我们就成功构建好了这棵二叉树: - -```c -int main(){ - ... - - printf("%c", a->left->left->element); //比如现在我想要获取A左孩子的左孩子,那么就可以直接left二连 -} -``` - -断点调试也可以看的很清楚: - -![image-20220806113156166](https://s2.loli.net/2022/08/06/oTPeUpBlmNsZWE1.png) - -### 二叉树的遍历 - -前面我们通过使用链式结构,成功构建出了一棵二叉树,接着我们来看看如何遍历一棵二叉树,也就是说我们想要访问二叉树的每一个结点,由于树形结构特殊,遍历顺序并不唯一,所以一共有四种访问方式:**前序遍历、中序遍历、后序遍历、层序遍历。**不同的访问方式输出都结点顺序也不同。 - -首先我们来看最简单的前序遍历: - -![image-20220806171459056](https://s2.loli.net/2022/08/06/G6ujstSVZ2XWJLE.png) - -前序遍历是一种勇往直前的态度,走到哪就遍历到那里,先走左边再走右边,比如上面的这个图,首先会从根节点开始: - -![image-20220806171431845](https://s2.loli.net/2022/08/06/qCFMosHtujEZ3U6.png) - -从A开始,先左后右,那么下一个就是B,然后继续走左边,是D,现在ABD走完之后,B的左边结束了,那么就要开始B的右边了,所以下一个是E,E结束之后,现在A的左子树已经全部遍历完成了,然后就是右边,接着就是C,C没有左子树了,那么只能走右边了,最后输出F,所以上面这个二叉树的前序遍历结果为:ABDECF - -1. 打印根节点 -2. 前序遍历左子树 -3. 前序遍历右子树 - -我们不难发现规律,整棵二叉树(包括子树)的根节点一定是出现在最前面的,比如A在最前面,A的左子树根结点B也是在最前面的。 - -接着我们来通过代码实现一下,首先先把咱们这棵二叉树组装好: - -```c -int main(){ - Node a = malloc(sizeof(struct TreeNode)); - Node b = malloc(sizeof(struct TreeNode)); - Node c = malloc(sizeof(struct TreeNode)); - Node d = malloc(sizeof(struct TreeNode)); - Node e = malloc(sizeof(struct TreeNode)); - Node f = malloc(sizeof(struct TreeNode)); - a->element = 'A'; - b->element = 'B'; - c->element = 'C'; - d->element = 'D'; - e->element = 'E'; - f->element = 'F'; - - a->left = b; - a->right = c; - b->left = d; - b->right = e; - c->right = f; - c->left = NULL; - d->left = e->right = NULL; - e->left = e->right = NULL; - f->left = f->right = NULL; -} -``` - -组装好之后,我们来实现一下前序遍历的函数: - -```c -void preOrder(Node root){ //传入的是二叉树的根结点 - -} -``` - -那么现在我们拿到根结点之后该怎么去写呢?既然是走到哪里打印到哪里,那么我们就先打印一下当前结点的值: - -```c -void preOrder(Node root){ - printf("%c", root->element); //不多bb先打印再说 -} -``` - -打印完成之后,我们就按照先左后右的规则往后遍历下一个结点,这里我们就直接使用递归来完成: - -```c -void preOrder(Node root){ - printf("%c", root->element); - preOrder(root->left); //将左孩子结点递归交给下一级 - preOrder(root->right); //等上面的一系列向左递归结束后,再以同样的方式去到右边 -} -``` - -不过还没,我们的递归肯定是需要一个终止条件的,不可能无限地进行下去,如果已经走到底了,那么就不能再往下走了,所以: - -```c -void preOrder(Node root){ - if(root == NULL) return; //如果走到NULL了,那就表示已经到头了,直接返回 - printf("%c", root->element); - preOrder(root->left); - preOrder(root->right); -} -``` - -最后我们来测试一下吧: - -```c -int main(){ - ... - - preOrder(a); -} -``` - -可以看到结果为: - -![image-20220806173227580](https://s2.loli.net/2022/08/06/hZ8qEfWaP5o6L2j.png) - -这样我们就通过一个简单的递归操作完成了对一棵二叉树的前序遍历,如果不太好理解,建议结合调试进行观察。 - -当然也有非递归的写法,我们使用循环,但是就比较麻烦了,我们需要使用栈来帮助我们完成(实际上递归写法本质上也是在利用栈),我们依然是从第一个结点开始,先走左边,每向下走一步,先输出节点的值,然后将对应的结点丢到栈中,当走到尽头时,表示左子树已经遍历完成,接着就是从栈中依次取出栈顶节点,如果栈顶结点有右子树,那么再按照同样的方式遍历其右子树,重复执行上述操作,直到栈清空为止。 - -* 一路向左,不断入栈,直到尽头 -* 到达尽头后,出栈,看看有没有右子树,如果没有就继续出栈,直到遇到有右子树的为止 -* 拿到右子树后,从右子树开始,重复上述步骤,直到栈清空 - -比如我们还是以上面的这棵树为例: - -![image-20220806171459056](https://s2.loli.net/2022/08/06/G6ujstSVZ2XWJLE.png) - -首先我们依然从根结点A出发,不断遍历左子树,沿途打印结果并将节点丢进栈中: - -![image-20220806215229564](https://s2.loli.net/2022/08/06/e1Nf5WhQdY9VGOD.png) - -当遍历到D结点时,没有左子树了,此时将栈顶结点D出栈,发现没有右节点,继续出栈,得到B结点,接着得到当前结点的右孩子E结点,然后重复上述步骤: - -![image-20220806220752941](https://s2.loli.net/2022/08/06/pZ6FRWn9wNg1JhY.png) - -接着发现E也没有左子树了,同样的,又开始出栈,此时E没有右子树,接着看A,A有右子树,所以继续从C开始,重复上述步骤: - -![image-20220806221147022](https://s2.loli.net/2022/08/06/K73cGsRUP6WO5iu.png) - -由于C之后没有左子树,那么就出栈获取右子树,此时得到结点F,继续重复上述步骤: - -![image-20220806221239705](https://s2.loli.net/2022/08/06/zkZisVY9H2qAafL.png) - -最后F出栈,没有右子树了,栈空,结束。 - -按照这个思路,我们来编写一下程序吧: - -```c -typedef char E; - -struct TreeNode { - E element; - struct TreeNode * left; - struct TreeNode * right; -}; - -typedef struct TreeNode * Node; - -//------------- 栈 ------------------- -typedef Node T; //这里栈内元素类型定义为上面的Node,也就是二叉树结点指针 - -struct StackNode { - T element; - struct StackNode * next; -}; - -typedef struct StackNode * SNode; //这里就命名为SNode,不然跟上面冲突了就不好了 - -void initStack(SNode head){ - head->next = NULL; -} - -_Bool pushStack(SNode head, T element){ - SNode node = malloc(sizeof(struct StackNode)); - if(node == NULL) return 0; - node->next = head->next; - node->element = element; - head->next = node; - return 1; -} - -_Bool isEmpty(SNode head){ - return head->next == NULL; -} - -T popStack(SNode head){ - SNode top = head->next; - head->next = head->next->next; - T e = top->element; - free(top); - return e; -} - -//------------------------------------- - -void preOrder(Node root){ - struct StackNode stack; //栈先搞出来 - initStack(&stack); - while (root || !isEmpty(&stack)){ //两个条件,只有当栈空并且节点为NULL时才终止循环 - while (root) { //按照我们的思路,先不断遍历左子树,直到没有为止 - pushStack(&stack, root); //途中每经过一个结点,就将结点丢进栈中 - printf("%c", root->element); //然后打印当前结点元素值 - root = root->left; //继续遍历下一个左孩子结点 - } - root = popStack(&stack); //经过前面的循环,明确左子树全部走完了,接着就是右子树了 - root = root->right; //得到右孩子,如果有右孩子,下一轮会重复上面的步骤;如果没有右孩子那么这里的root就被赋值为NULL了,下一轮开始会直接跳过上面的while,继续出栈下一个结点再找右子树 - } -} -``` - -这样,我们就通过非递归的方式实现了前序遍历,可以看到代码是相当复杂的,也不推荐这样编写。 - -那么前序遍历我们了解完了,接着就是中序遍历了,中序遍历在顺序上与前序遍历不同,前序遍历是走到哪就打印到哪,而中序遍历需要先完成整个左子树的遍历后再打印,然后再遍历其右子树。 - -我们还是以上面的二叉树为例: - -![image-20220806230603967](https://s2.loli.net/2022/08/06/W6Yb5M92gQApNJa.png) - -首先需要先不断遍历左子树,走到最底部,但是沿途并不进行打印,而是到底之后,再打印,所以第一个打印的是D,接着由于没有右子树,所以我们回到B,此时再打印B,然后再去看B的右结点E,由于没有左子树和右子树了,所以直接打印E,左边遍历完成,接着回到A,打印A,然后对A的右子树重复上述操作。所以说遍历的基本规则还是一样的,只是打印值的时机发生了改变。 - -1. 中序遍历左子树 -2. 打印结点 -3. 中序遍历右子树 - -所以这棵二叉树的中序遍历结果为:DBEACF,我们可以发现一个规律,就是在某个结点的左子树中所有结点,其中序遍历结果也是按照这样的规律排列的,比如A的左子树中所有结点,中序遍历结果中全部都在A的左边,右子树中所有的结点,全部都在A的右边(这个规律很关键,后面在做一些算法题时会用到) - -那么怎么才能将打印调整到左子树全部遍历结束之后呢?其实很简单: - -```c -void inOrder(Node root){ - if(root == NULL) return; - inOrder(root->left); //先完成全部左子树的遍历 - printf("%c", root->element); //等待左子树遍历完成之后再打印 - inOrder(root->right); //然后就是对右子树进行遍历 -} -``` - -我们只需要将打印放到左子树遍历之后即可,这样打印出来的结果就是中序遍历的结果了: - -![image-20220806231752418](https://s2.loli.net/2022/08/06/V2KdMy3T5Beo8vx.png) - -同样的,如果采用的是非递归,那么我也只需要稍微改动一个地方即可: - -```c -... - -void inOrder(Node root){ - struct StackNode stack; - initStack(&stack); - while (root || !isEmpty(&stack)){ //其他都不变 - while (root) { - pushStack(&stack, root); - root = root->left; - } - root = popStack(&stack); - printf("%c", root->element); //只需要将打印时机延后到左子树遍历完成 - root = root->right; - } -} -``` - -这样,我们就实现了二叉树的中序遍历,实际上还是很好理解的。 - -接着我们来看一下后序遍历,后序遍历继续将打印的时机延后,需要等待左右子树全部遍历完成,才会去进行打印。 - -![image-20220806233407910](https://s2.loli.net/2022/08/06/YE2rODdqpCInUa9.png) - -首先还是一路向左,到达结点D,此时结点D没有左子树了,接着看结点D还有没有右子树,发现也没有,左右子树全部遍历完成,那么此时再打印D,同样的,D完事之后就回到B了,此时接着看B的右子树,发现有结点E,重复上述操作,E也打印出来了,接着B的左右子树全部OK,那么再打印B,接着A的左子树就完事了,现在回到A,看到A的右子树,继续重复上述步骤,当A的右子树也遍历结束后,最后再打印A结点。 - -1. 后序遍历左子树 -2. 后序遍历右子树 -3. 打印结点 - -所以最后的遍历顺序为:DEBFCA,不难发现,整棵二叉树(包括子树)根结点一定是在后面的,比如A在所有的结点的后面,B在其子节点D、E的后面,这一点恰恰和前序遍历相反(注意不是得到的结果相反,是规律相反) - -所以,按照这个思路,我们来编写一下后序遍历: - -```c -void postOrder(Node root){ - if(root == NULL) return; - postOrder(root->left); - postOrder(root->right); - printf("%c", root->element); //时机延迟到最后 -} -``` - -结果如下: - -![image-20220806234428922](https://s2.loli.net/2022/08/06/6Vx9fmSUcqw51Mp.png) - -不过难点来了,后序遍历使用非递归貌似写不了啊?因为按照我们的之前的思路,最多也就实现中序遍历,我们没办法在一次循环中得知右子树是否完成遍历,难点就在这里。那么我们就要想办法先让右子树完成遍历,由于一个结点需要左子树全部完成+右子树全部完成,而目前只能明确左子树完成了遍历(也就是内层while之后,左子树一定结束了)所以我们可以不急着将结点出栈,而是等待其左右都完事了再出栈,这里我们需要稍微对结点的结构进行修改,添加一个标记变量,来表示已经完成左边还是左右都完成了: - -```c -struct TreeNode { - E element; - struct TreeNode * left; - struct TreeNode * right; - int flag; //需要经历左右子树都被遍历才行,这里用flag存一下状态,0表示左子树遍历完成,1表示右子树遍历完成 -}; -``` - -```c -T peekStack(SNode head){ //这里新增一个peek操作,用于获取栈顶元素的值,但是不出栈,仅仅是值获取 - return head->next->element; -} -``` - -```c -void postOrder(Node root){ - struct StackNode stack; - initStack(&stack); - while (root || !isEmpty(&stack)){ //其他都不变 - while (root) { - pushStack(&stack, root); - root->flag = 0; //首次入栈时,只能代表左子树遍历完成,所以flag置0 - root = root->left; - } - root = peekStack(&stack); //注意这里只是获取到结点,并没有进行出栈操作,因为需要等待右子树遍历完才能出栈 - if(root->flag == 0) { //如果仅仅遍历了左子树,那么flag就等于0 - root->flag = 1; //此时标记为1表示遍历右子树 - root = root->right; //这里跟之前是一样的 - } else { - printf("%c", root->element); //当flag为1时走这边,此时左右都遍历完成了,这时再打印值出来 - popStack(&stack); //这时再把对应的结点出栈,因为左右都完事了 - root = NULL; //置为NULL,下一轮直接跳过while,然后继续取栈中剩余的结点,重复上述操作 - } - } -} -``` - -所以,后序遍历的非递归写法的最大区别是将结点的出栈时机和打印时机都延后了。 - -最后我们来看层序遍历,实际上这种遍历方式是我们人脑最容易理解的,它是按照每一层在进行遍历: - -![image-20220807205135936](https://s2.loli.net/2022/08/07/ywF6r9MU1JSPIge.png) - -层序遍历实际上就是按照从上往下每一层,从左到右的顺序打印每个结点,比如上面的这棵二叉树,那么层序遍历的结果就是:ABCDEF,像这样一层一层的挨个输出。 - -虽然理解起来比较简单,但是如果让你编程写出来,该咋搞?是不是感觉有点无从下手? - -我们可以利用队列来实现层序遍历,首先将根结点存入队列中,接着循环执行以下步骤: - -* 进行出队操作,得到一个结点,并打印结点的值。 -* 将此结点的左右孩子结点依次入队。 - -不断重复以上步骤,直到队列为空。 - -我们来分析一下,首先肯定一开始A在里面: - -![image-20220807211522409](https://s2.loli.net/2022/08/07/ZsNpeVUivEjCymt.png) - -接着开始不断重复上面的步骤,首先是将队首元素出队,打印A,然后将A的左右孩子依次入队: - -![image-20220807211631110](https://s2.loli.net/2022/08/07/v8yXWNato3sfeUn.png) - -现在队列中有B、C两个结点,继续重复上述操作,B先出队,打印B,然后将B的左右孩子依次入队: - -![image-20220807211723776](https://s2.loli.net/2022/08/07/Qkprfi5RhAXP7Cd.png) - -现在队列中有C、D、E这三个结点,继续重复,C出队并打印,然后将F入队: - -![image-20220807211800852](https://s2.loli.net/2022/08/07/MxQTArlWK2gDjqi.png) - -我们发现,这个过程中,打印的顺序正好就是我们层序遍历的顺序,所以说队列还是非常有用的。 - -那么现在我们就来上代码吧: - -```c -typedef char E; - -struct TreeNode { - E element; - struct TreeNode * left; - struct TreeNode * right; - int flag; -}; - -typedef struct TreeNode * Node; - -//--------------- 队列 ---------------- -typedef Node T; //还是将Node作为元素 - -struct QueueNode { - T element; - struct QueueNode * next; -}; - -typedef struct QueueNode * QNode; - -struct Queue{ - QNode front, rear; -}; - -typedef struct Queue * LinkedQueue; - -_Bool initQueue(LinkedQueue queue){ - QNode node = malloc(sizeof(struct QueueNode)); - if(node == NULL) return 0; - queue->front = queue->rear = node; - return 1; -} - -_Bool offerQueue(LinkedQueue queue, T element){ - QNode node = malloc(sizeof(struct QueueNode)); - if(node == NULL) return 0; - node->element = element; - queue->rear->next = node; - queue->rear = node; - return 1; -} - -_Bool isEmpty(LinkedQueue queue){ - return queue->front == queue->rear; -} - -T pollQueue(LinkedQueue queue){ - T e = queue->front->next->element; - QNode node = queue->front->next; - queue->front->next = queue->front->next->next; - if(queue->rear == node) queue->rear = queue->front; - free(node); - return e; -} -//-------------------------------- - -void levelOrder(Node root){ - struct Queue queue; //先搞一个队列 - initQueue(&queue); - offerQueue(&queue, root); //先把根节点入队 - while (!isEmpty(&queue)) { //不断重复,直到队列空为止 - Node node = pollQueue(&queue); //出队一个元素,打印值 - printf("%c", node->element); - if(node->left) //如果存在左右孩子的话 - offerQueue(&queue, node->left); //记得将左右孩子入队,注意顺序,先左后右 - if(node->right) - offerQueue(&queue, node->right); - } -} -``` - -可以看到结果就是层序遍历的结果: - -![image-20220807215630429](https://s2.loli.net/2022/08/07/YlUfDhPoQrg9TkB.png) - -当然,使用递归也可以实现,但是需要单独存放结果然后单独输出,不是很方便,所以说这里就不演示了。 - -**二叉树练习题:** - -1. 现在有一棵二叉树前序遍历结果为:ABCDE,中序遍历结果为:BADCE,那么请问该二叉树的后序遍历结果为? - -2. 对二叉树的结点从1开始连续进行编号,要求每个结点的编号大于其左右孩子的编号,那么请问需要采用哪种遍历方式来实现? - - A. 前序遍历 B. 中序遍历 **C. 后序遍历** D. 层序遍历 - -*** - -## 高级树结构 - -高级树结构篇是对树结构的延伸扩展,有着特殊的定义和性质,在编写上可能会比较复杂,所以这一部分对于那些太过复杂的结构,就不进行代码编写了,只进行理论讲解。 - -### 线索化二叉树 - -前面我们学习了二叉树,我们知道一棵二叉树实际上可以由多个结点组成,每个结点都有一个左右指针,指向其左右孩子。我们在最后也讲解了二叉树的遍历,包括前序、中序、后序以及层序遍历。只不过在遍历时实在是太麻烦了,我们需要借助栈来帮助我们完成这项遍历操作。 - -实际上我们发现,一棵二叉树的某些结点会存在NULL的情况,我们可以利用这些为NULL的指针,将其线索化为某一种顺序遍历的指向下一个按顺序的结点的指针,这样我们在进行遍历的时候,就会很方便了。 - -例如,一棵二叉树的前序遍历顺序如下: - -![image-20220814145531577](https://s2.loli.net/2022/08/14/ZRjFywa6kWHrbJY.png) - -我们就可以将其进行线索化,首先还是按照前序遍历的顺序依次寻找: - -![image-20220814150731326](https://s2.loli.net/2022/08/14/Wu954jeLJhbxXDr.png) - -线索化的规则为: - -* 结点的左指针,指向其当前遍历顺序的前驱结点。 -* 结点的右指针,指向其当前遍历顺序的后继结点。 - -所以在线索化之后,G的指向情况如下: - -![image-20220814151342130](https://s2.loli.net/2022/08/14/ExhJStz4eMoCRF1.png) - -这样,G原本两个为NULL的指针就被我们利用起来了,但是现在有一个问题,我们怎么知道,某个结点的指针到底是指向的其左右孩子,还是说某种遍历顺序下的前驱或是后继结点呢?所以,我们还需要分别为左右添加一个标志位,来表示左右指针到底指向的是孩子还是遍历线索: - -```c -typedef char E; - -typedef struct TreeNode { - E element; - struct TreeNode * left; - struct TreeNode * right; - int leftTag, rightTag; //标志位,如果为1表示这一边指针指向的是线索,不为1就是正常的孩子结点 -} * Node; -``` - -接着是H结点,同样的,因为H结点的左右指针都是NULL,那么我们也可以将其线索化: - -![image-20220814152008732](https://s2.loli.net/2022/08/14/pVo6FHquyBWmS7f.png) - -接着我们来看结点E,这个结点只有一个右孩子,没有左孩子,左孩子指针为NULL,我们也可以将其线索化: - -![image-20220814152117861](https://s2.loli.net/2022/08/14/2nUPAuVOvcQKw7L.png) - -最后,整棵二叉树完成线索化之后,除了遍历顺序的最后一个结点没有后续之外,其他为NULL的指针都被利用起来了: - -![image-20220814152341658](https://s2.loli.net/2022/08/14/SpWPAbzXRFcOgZJ.png) - -我们可以发现,在利用上那些为NULL的指针之后,当我们再次进行前序遍历时,我们不需要再借助栈了,而是可以一路向前。 - -这里我们弄一个简单一点的线索化二叉树,来尝试对其进行遍历: - -![image-20220814152703468](https://s2.loli.net/2022/08/14/E1YyemquOdasTRi.png) - -首先我们要对这棵二叉树进行线索化,将其变成一棵线索化二叉树: - -```c -Node createNode(E element){ //单独写了个函数来创建结点 - Node node = malloc(sizeof(struct TreeNode)); - node->left = node->right = NULL; - node->rightTag = node->leftTag = 0; - node->element = element; - return node; -} - -int main() { - Node a = createNode('A'); - Node b = createNode('B'); - Node c = createNode('C'); - Node d = createNode('D'); - Node e = createNode('E'); - - a->left = b; - b->left = d; - a->right = c; - b->right = e; -} -``` - -实际上要将其进行线索化,我们只需要正常按照对应的遍历顺序进行即可,不过在遍历过程中需要留意那些存在空指针的结点,我们需要修改其指针的指向: - -```c -void preOrderThreaded(Node root){ //前序遍历线索化函数 - if(root == NULL) return; - //别急着写打印 - preOrderThreaded(root->left); - preOrderThreaded(root->right); -} -``` - -首先还是老规矩,先把前序遍历写出来,然后我们需要进行判断,如果存在指针指向为NULL,那么就将其线索化: - -```c -Node pre = NULL; //这里我们需要一个pre来保存后续结点的指向 -void preOrderThreaded(Node root){ //前序遍历线索化函数 - if(root == NULL) return; - - if(root->left == NULL) { //首先判断当前结点左边是否为NULL,如果是,那么指向上一个结点 - root->left = pre; - root->leftTag = 1; //记得修改标记 - } - if(pre && pre->right == NULL) { //然后是判断上一个结点的右边是否为NULL,如果是那么进行线索化,指向当前结点 - pre->right = root; - pre->rightTag = 1; //记得修改标记 - } - - pre = root; //每遍历完一个,需要更新一下pre,表示上一个遍历的结点 - - if(root->leftTag == 0) //注意只有标志位是0才可以继续向下,否则就是线索了 - preOrderThreaded(root->left); - if(root->rightTag == 0) - preOrderThreaded(root->right); -} -``` - -这样,在我们进行二叉树的遍历时,会自动将其线索化,线索化完成之后就是一棵线索化二叉树了。 - -![image-20220814154539765](https://s2.loli.net/2022/08/14/kxhAsiWCSYMdB7q.png) - -可以看到结点D的左右标记都是1,说明都被线索化了,并且D的左边指向的是其前一个结点B,右边指向的是后一个结点E,这样我们就成功将其线索化了。 - -现在我们成功得到了一棵线索化之后的二叉树,那么怎么对其进行遍历呢?我们只需要一个简单的循环就可以了: - -```c -void preOrder(Node root){ //前序遍历一棵线索化二叉树非常简单 - while (root) { //到头为止 - printf("%c", root->element); //因为是前序遍历,所以直接按顺序打印就行了 - if(root->leftTag == 0) - root = root->left; //如果是左孩子,那么就走左边 - else - root = root->right; //如果左边指向的不是孩子,而是线索,那么就直接走右边,因为右边无论是线索还是孩子,都要往这边走了 - } -} -``` - -我们接着来看看中序遍历的线索化二叉树,整个线索化过程我们只需要稍微调整位置就行了: - -```c -Node pre = NULL; //这里我们需要一个pre来保存后续结点的指向 -void inOrderThreaded(Node root){ //前序遍历线索化函数 - if(root == NULL) return; - if(root->leftTag == 0) - inOrderThreaded(root->left); - - //------ 线索化 ------- 现在放到中间去,其他的还是一样的 - if(root->left == NULL) { - root->left = pre; - root->leftTag = 1; - } - if(pre && pre->right == NULL) { - pre->right = root; - pre->rightTag = 1; - } - pre = root; - //-------------------- - - if(root->rightTag == 0) - inOrderThreaded(root->right); -} -``` - -最后我们线索化完成之后,长这样了: - -![image-20220814161529021](https://s2.loli.net/2022/08/14/tsEJLRFCYVaTOP8.png) - -那么像这样的一棵树,我们怎么对其进行遍历呢?中序遍历要稍微麻烦一些: - -```c -void inOrder(Node root){ - while (root) { //因为中序遍历需要先完成左边,所以说要先走到最左边才行 - while (root && root->leftTag == 0) //如果左边一直都不是线索,那么就一直往左找,直到找到一个左边是线索的为止,表示到头了 - root = root->left; - - printf("%c", root->element); //到最左边了再打印,中序开始 - - while (root && root->rightTag == 1) { //打印完就该右边了,右边如果是线索化之后的结果,表示是下一个结点,那么就一路向前,直到不是为止 - root = root->right; - printf("%c", root->element); //注意按着线索往下就是中序的结果,所以说沿途需要打印 - } - root = root->right; //最后继续从右结点开始,重复上述操作 - } -} -``` - -最后我们来看看后序遍历的线索化,同样的,我们只需要在线索化时修改为后序就行了 - -```c -Node pre = NULL; //这里我们需要一个pre来保存后续结点的指向 -void inOrderThreaded(Node root){ //前序遍历线索化函数 - if(root == NULL) return; - if(root->leftTag == 0) - inOrderThreaded(root->left); - if(root->rightTag == 0) - inOrderThreaded(root->right); - //------ 线索化 ------- 现在这一坨移到最后,就是后序遍历的线索化了 - if(root->left == NULL) { - root->left = pre; - root->leftTag = 1; - } - if(pre && pre->right == NULL) { - pre->right = root; - pre->rightTag = 1; - } - pre = root; - //-------------------- -} -``` - -线索化完成之后,变成一棵后续线索化二叉树: - -![image-20220814162606692](https://s2.loli.net/2022/08/14/Smqt1UKjeWXFRPu.png) - -后序遍历的结果看起来有点怪怪的,但是这就是后序,那么怎么对这棵线索化二叉树进行后续遍历呢?这就比较复杂了。首先后续遍历需要先完成左右,左边还好说,关键是右边,右边完事之后我们并不一定能找到对应子树的根结点,比如我们按照上面的线索,先从D开始,根据线索找到E,然后继续跟据线索找到B,但是此时B无法找到其兄弟结点C,所以说这样是行不通的,因此要完成后续遍历,我们只能对结点进行改造: - -```c -typedef struct TreeNode { - E element; - struct TreeNode * left; - struct TreeNode * right; - struct TreeNode * parent; //指向双亲(父)结点 - int leftTag, rightTag; -} * Node; -``` - -现在每个结点都保存其父结点,这样就可以顺利地找上去了。现在我们来编写一下吧: - -```c -Node pre = NULL; //这里我们需要一个pre来保存后续结点的指向 -void postOrderThreaded(Node root){ //前序遍历线索化函数 - if(root == NULL) return; - if(root->leftTag == 0) { - postOrderThreaded(root->left); - if(root->left) root->left->parent = root; //左边完事之后,如果不为空,那么就设定父子关系 - } - if(root->rightTag == 0) { - postOrderThreaded(root->right); - if(root->right) root->right->parent = root; //右边完事之后,如果不为空,那么就设定父子关系 - } - //------ 线索化 ------- - if(root->left == NULL) { - root->left = pre; - root->leftTag = 1; - } - if(pre && pre->right == NULL) { - pre->right = root; - pre->rightTag = 1; - } - pre = root; - //-------------------- -} -``` - -后序遍历代码如下: - -```c -void postOrder(Node root){ - Node last = NULL, node = root; //这里需要两个暂存指针,一个记录上一次遍历的结点,还有一个从root开始 - while (node) { - while (node->left != last && node->leftTag == 0) //依然是从整棵树最左边结点开始,和前面一样,只不过这里加入了防无限循环机制,看到下面就知道了 - node = node->left; - while (node && node->rightTag == 1) { //左边完了还有右边,如果右边是线索,那么直接一路向前,也是跟前面一样的 - printf("%c", node->element); //沿途打印 - last = node; - node = node->right; - } - if (node == root && node->right == last) { - //上面的操作完成之后,那么当前结点左右就结束了,此时就要去寻找其兄弟结点了,我们可以 - //直接通过parent拿到兄弟结点,但是如果当前结点是根结点,需要特殊处理,因为根结点没有父结点了 - printf("%c", node->element); - return; //根节点一定是最后一个,所以说直接返回就完事 - } - while (node && node->right == last) { //如果当前结点的右孩子就是上一个遍历的结点,那么一直向前就行 - printf("%c", node->element); //直接打印当前结点 - last = node; - node = node->parent; - } - //到这里只有一种情况了,是从左子树上来的,那么当前结点的右边要么是线索要么是右子树,所以直接向右就完事 - if(node && node->rightTag == 0) { //如果不是线索,那就先走右边,如果是,等到下一轮再说 - node = node->right; - } - } -} -``` - -至此,有关线索化二叉树,我们就讲解到这样。 - -### 二叉查找树 - -还记得我们开篇讲到的二分搜索算法吗?通过不断缩小查找范围,最终我们可以以很高的效率找到有序数组中的目标位置。而二叉查找树则利用了类似的思想,我们可以借助其来像二分搜索那样快速查找。 - -**二叉查找树**也叫二叉搜索树或是二叉排序树,它具有一定的规则: - -* 左子树中所有结点的值,均小于其根结点的值。 -* 右子树中所有结点的值,均大于其根结点的值。 -* 二叉搜索树的子树也是二叉搜索树。 - -一棵二叉搜索树长这样: - -![image-20220814191444130](https://s2.loli.net/2022/08/14/k9G7Ad2cqezgEtJ.png) - -这棵树的根结点为18,而其根结点左边子树的根结点为10,包括后续结点,都是满足上述要求的。二叉查找树满足左边一定比当前结点小,右边一定比当前结点大的规则,比如我们现在需要在这颗树种查找值为15的结点: - -1. 从根结点18开始,因为15小于18,所以从左边开始找。 -2. 接着来到10,发现10比15小,所以继续往右边走。 -3. 来到15,成功找到。 - -实际上,我们在对普通二叉树进行搜索时,可能需要挨个进行查看比较,而有了二叉搜索树,查找效率就大大提升了,它就像我们前面的二分搜索那样。 - -因为二叉搜索树要求比较严格,所以我们在插入结点时需要遵循一些规律,这里我们来尝试编写一下: - -```c -#include -#include - -typedef int E; - -typedef struct TreeNode { - E element; - struct TreeNode * left; - struct TreeNode * right; -} * Node; - -Node createNode(E element){ - Node node = malloc(sizeof(struct TreeNode)); - node->left = node->right = NULL; - node->element = element; - return node; -} - -int main() { - -} -``` - -我们就以上面这颗二叉查找树为例,现在我们想要依次插入这些结点,我们需要编写一个特殊的插入操作,这里需要注意一下,二叉查找树不能插入重复元素,如果出现重复直接忽略: - -```c -Node insert(Node root, E element){ - if(root){ - if(root->element > element) //如果插入结点值小于当前结点,那么说明应该放到左边去 - root->left = insert(root->left, element); - else if(root->element < element) //如果插入结点值大于当前结点,那么说明应该放到右边去 - root->right = insert(root->right, element); - } else { //当结点为空时,说明已经找到插入的位置了,创建对应结点 - root = createNode(element); - } - return root; //返回当前结点 -} -``` - -这样我们就可以通过不断插入创建一棵二叉查找树了: - -```c -void inOrder(Node root){ - if(root == NULL) return; - inOrder(root->left); - printf("%d ", root->element); - inOrder(root->right); -} - -int main() { - Node root = insert(NULL, 18); //插入后,得到根结点 - inOrder(root); //用中序遍历查看一下结果 -} -``` - -我们按照顺序来,首先是根结点的左右孩子,分别是10和20,那么这里我们就依次插入一下: - -```c -int main() { - Node root = insert(NULL, 18); //插入后,得到根结点 - insert(root, 10); - insert(root, 20); - inOrder(root); -} -``` - -可以看到中序结果为: - -![image-20220815094708456](https://s2.loli.net/2022/08/15/FlLRBprEezot5Z8.png) - -比18小的结点在左边,大的在右边,满足二叉查找树的性质。接着是7、15、22: - -![image-20220815094823646](https://s2.loli.net/2022/08/15/chEUaOBzCTl4N8G.png) - -最后再插入9就是我们上面的这棵二叉查找树了。当然我们直接写成控制台扫描的形式,就更方便了: - -```c -int main() { - Node root = NULL; - while (1) { - E element; - scanf("%d", &element); - root = insert(root, element); - inOrder(root); - putchar('\n'); - } -} -``` - -那么插入写好之后,我们怎么找到对应的结点呢?实际上也是按照规律来就行了: - -```c -Node find(Node root, E target){ - while (root) { - if(root->element > target) //如果要找的值比当前结点小,说明肯定在左边 - root = root->left; - else if(root->element < target) //如果要找的值比当前结点大,说明肯定在右边 - root = root->right; - else - return root; //等于的话,说明找到了,就直接返回 - } - return NULL; //都找到底了还没有,那就是真没有了 -} - -Node findMax(Node root){ //查找最大值就更简单了,最右边的一定是最大的 - while (root && root->right) - root = root->right; - return root; -} -``` - -我们来尝试查找一下: - -```c -int main() { - Node root = insert(NULL, 18); //插入后,得到根结点 - insert(root, 10); - insert(root, 20); - insert(root, 7); - insert(root, 15); - insert(root, 22); - insert(root, 9); - - printf("%p\n", find(root, 17)); - printf("%p\n", find(root, 9)); -} -``` - -![image-20220815095915453](https://s2.loli.net/2022/08/15/lFOaUphkbB3wxIC.png) - -搜索17的结果为NULL,说明没有这个结点,而9则成功找到了。 - -最后我们来看看二叉查找树的删除操作,这个操作就比较麻烦了,因为可能会出现下面的几种情况: - -1. 要删除的结点是叶子结点。 -2. 要删除的结点是只有一个孩子结点。 -3. 要删除的结点有两个孩子结点。 - -首先我们来看第一种情况,这种情况实际上最好办,直接删除就完事了: - -![image-20220815104036598](https://s2.loli.net/2022/08/15/7RWkPXh6po2HjNz.png) - -而第二种情况,就有点麻烦了,因为有一个孩子,就像一个拖油瓶一样,你离开了还不行,你还得对他负责才可以。当移除后,需要将孩子结点连接上去: - -![image-20220815104553978](https://s2.loli.net/2022/08/15/4IZVf3SaCugD8Qc.png) - -可以看到在调整后,依然满足二叉查找树的性质。最后是最麻烦的有两个孩子的情况,这种该怎么办呢?前面只有一个孩子直接上位就完事,但是现在两个孩子,到底谁上位呢?这就不好办了,为了保持二叉查找树的性质,现在有两种选择: - -1. 选取其左子树中最大结点上位 -2. 选择其右子树中最小结点上位 - -这里我们以第一种方式为例: - -![image-20220815110311555](https://s2.loli.net/2022/08/15/jPRG68tru4bvIFa.png) - -现在我们已经分析完三种情况了,那么我们就来编写一下代码吧: - -```c -Node delete(Node root, E target){ - if(root == NULL) return NULL; //都走到底了还是没有找到要删除的结点,说明没有,直接返回空 - if(root->element > target) //这里的判断跟之前插入是一样的,继续往后找就完事,直到找到为止 - root->left = delete(root->left, target); - else if(root->element < target) - root->right = delete(root->right, target); - else { //这种情况就是找到了 - if(root->left && root->right) { //先处理最麻烦的左右孩子都有的情况 - Node max = findMax(root->left); //寻找左子树中最大的元素 - root->element = max->element; //找到后将值替换 - root->left = delete(root->left, root->element); //替换好后,以同样的方式去删除那个替换上来的结点 - } else { //其他两种情况可以一起处理,只需要删除这个结点就行,然后将root指定为其中一个孩子,最后返回就完事 - Node tmp = root; - if(root->right) { //不是左边就是右边 - root = root->right; - } else { - root = root->left; - } - free(tmp); //开删 - } - } - return root; //返回最终的结点 -} -``` - -这样,我们就完成了二叉查找树的各种操作,当然目前为止我们了解的二叉树高级结构还比较简单,后面就开始慢慢复杂起来了。 - -### 平衡二叉树 - -前面我们介绍了二叉查找树,利用二叉查找树,我们在搜索某个值的时候,效率会得到巨大提升。但是虽然看起来比较完美,也是存在缺陷的,比如现在我们依次将下面的值插入到这棵二叉树中: - -``` -20 15 13 8 6 3 -``` - -在插入完成后,我们会发现这棵二叉树竟然长这样: - -![image-20220815113242191](https://s2.loli.net/2022/08/15/E1Pf2pGv4b9Lj7t.png) - -因为根据我们之前编写的插入规则,小的一律往左边放,现在正好来的就是这样一串递减的数字,最后就组成了这样的一棵只有一边的二叉树,这种情况,与其说它是一棵二叉树,不如说就是一个链表,如果这时我们想要查找某个结点,那么实际上查找的时间并没有得到任何优化,直接就退化成线性查找了。 - -所以,二叉查找树只有在理想情况下,查找效率才是最高的,而像这种极端情况,就性能而言几乎没有任何的提升。我们理想情况下,这样的效率是最高的: - -![image-20220815113705827](https://s2.loli.net/2022/08/15/k1jzXPoOMp9caHy.png) - -所以,我们在进行结点插入时,需要尽可能地避免这种一边倒的情况,这里就需要引入**平衡二叉树**的概念了。实际上我们发现,在插入时如果不去维护二叉树的平衡,某一边只会无限制地延伸下去,出现极度不平衡的情况,而我们理想中的二叉查找树左右是尽可能保持平衡的,**平衡二叉树**(AVL树)就是为了解决这样的问题而生的。 - -它的性质如下: - -* 平衡二叉树一定是一棵二叉查找树。 -* 任意结点的左右子树也是一棵平衡二叉树。 -* 从根节点开始,左右子树都高度差不能超过1,否则视为不平衡。 - -可以看到,这些性质规定了平衡二叉树需要保持高度平衡,这样我们的查找效率才不会因为数据的插入而出现降低的情况。二叉树上节点的左子树高度 减去 右子树高度, 得到的结果称为该节点的**平衡因子**(Balance Factor),比如: - -![image-20220815210652973](https://s2.loli.net/2022/08/15/vaI9qji1KYOP8kt.png) - -通过计算平衡因子,我们就可以快速得到是否出现失衡的情况。比如下面的这棵二叉树,正在执行插入操作: - -![image-20220815115219250](https://s2.loli.net/2022/08/15/DMnPqGhawy5Z92V.png) - -可以看到,当插入之后,不再满足平衡二叉树的定义时,就出现了失衡的情况,而对于这种失衡情况,为了继续保持平衡状态,我们就需要进行处理了。我们可能会遇到以下几种情况导致失衡: - -![image-20220815115836604](https://s2.loli.net/2022/08/15/KcOQVhlFxzwsIb9.png) - -根据插入结点的不同偏向情况,分为LL型、LR型、RR型、RL型。针对于上面这几种情况,我们依次来看一下如何进行调整,使得这棵二叉树能够继续保持平衡: - -动画网站:https://www.cs.usfca.edu/~galles/visualization/AVLtree.html(实在不理解可以看看动画是怎么走的) - -1. **LL型调整**(右旋) - - ![image-20220815211641144](https://s2.loli.net/2022/08/15/KqBaWLJwOj34Ec8.png) - - 首先我们来看这种情况,这是典型的LL型失衡,为了能够保证二叉树的平衡,我们需要将其进行**旋转**来维持平衡,去纠正最小不平衡子树即可。那么怎么进行旋转呢?对于LL型失衡,我们只需要进行右旋操作,首先我们先找到最小不平衡子树,注意是最小的那一个: - - ![image-20220815212552176](https://s2.loli.net/2022/08/15/q4aYvzrnjdTgAtK.png) - - 可以看到根结点的平衡因子是2,是目前最小的出现不平衡的点,所以说从根结点开始向左的三个结点需要进行右旋操作,右旋需要将这三个结点中间的结点作为新的根结点,而其他两个结点现在变成左右子树: - - ![image-20220815213222964](https://s2.loli.net/2022/08/15/fJKz3FWclm9orVT.png) - - 这样,我们就完成了右旋操作,可以看到右旋之后,所有的结点继续保持平衡,并且依然是一棵二叉查找树。 - -2. **RR型调整**(左旋) - - 前面我们介绍了LL型以及右旋解决方案,相反的,当遇到RR型时,我们只需要进行左旋操作即可: - - ![image-20220815214026710](https://s2.loli.net/2022/08/15/kIl8ZT6Psr7mNSg.png) - - 操作和上面是一样的,只不过现在反过来了而已: - - ![image-20220815214408651](https://s2.loli.net/2022/08/15/LB9DOJpyIlxQWTm.png) - - 这样,我们就完成了左旋操作,使得这棵二叉树继续保持平衡状态了。 - -3. **RL型调整**(先右旋,再左旋) - - 剩下两种类型比较麻烦,需要旋转两次才行。我们来看看RL型长啥样: - - ![image-20220815214859501](https://s2.loli.net/2022/08/15/fwcrEIgBxWLVGXs.png) - - 可以看到现在的形状是一个回旋镖形状的,先右后左的一个状态,也就是RL型,针对于这种情况,我们需要先进行右旋操作,注意这里的右旋操作针对的是后两个结点: - - ![image-20220815215929303](https://s2.loli.net/2022/08/15/ukK6C4PNBwoaJbc.png) - - 其中右旋和左旋的操作,与之前一样,该怎么分配左右子树就怎么分配,完成两次旋转后,可以看到二叉树重新变回了平衡状态。 - -4. **LR型调整**(先左旋,再右旋) - - 和上面一样,我们来看看LR型长啥样,其实就是反着的: - - ![image-20220815220609357](https://s2.loli.net/2022/08/15/6Cj8VlgGekULXvP.png) - - 形状是先向左再向右,这就是典型的LR型了,我们同样需要对其进行两次旋转: - - ![image-20220815221349044](https://s2.loli.net/2022/08/15/y6WscFPxHuzTiaI.png) - - 这里我们先进行的是左旋,然后再进行的右旋,这样二叉树就能继续保持平衡了。 - -这样,我们只需要在插入结点时注意维护整棵树的平衡因子,保证其处于稳定状态,这样就可以让这棵树一直处于高度平衡的状态,不会再退化了。这里我们就编写一个插入结点代码来实现一下吧,首先还是结点定义: - -```c -typedef int E; - -typedef struct TreeNode { - E element; - struct TreeNode * left; - struct TreeNode * right; - int height; //每个结点需要记录当前子树的高度,便于计算平衡因子 -} * Node; - -Node createNode(E element){ - Node node = malloc(sizeof(struct TreeNode)); - node->left = node->right = NULL; - node->element = element; - node->height = 1; //初始化时,高度写为1就可以了 - return node; -} -``` - -接着我们需要先将左旋、右旋等操作编写出来,因为一会插入时可能需要用到: - -```c -int max(int a, int b){ - return a > b ? a : b; -} - -int getHeight(Node root){ - if(root == NULL) return 0; - return root->height; -} - -Node leftRotation(Node root){ //左旋操作,实际上就是把左边结点拿上来 - Node newRoot = root->right; //先得到左边结点 - root->right = newRoot->left; //将左边结点的左子树丢到原本根结点的右边去 - newRoot->left = root; //现在新的根结点左边就是原本的跟结点了 - - root->height = max(getHeight(root->right), getHeight(root->left)) + 1; - newRoot->height = max(getHeight(newRoot->right), getHeight(newRoot->left)) + 1; - return newRoot; -} - -Node rightRotation(Node root){ - Node newRoot = root->left; - root->left = newRoot->right; - newRoot->right = root; - - root->height = max(getHeight(root->right), getHeight(root->left)) + 1; - newRoot->height = max(getHeight(newRoot->right), getHeight(newRoot->left)) + 1; - return newRoot; -} - -Node leftRightRotation(Node root){ - root->left = leftRotation(root->left); - return rightRotation(root); -} - -Node rightLeftRightRotation(Node root){ - root->right = rightRotation(root->right); - return leftRotation(root); -} -``` - -最后就是我们的插入操作了,注意在插入时动态计算树的高度,一旦发现不平衡,那么就立即采取对应措施: - -```c -Node insert(Node root, E element){ - if(root == NULL) { //如果结点为NULL,说明找到了插入位置,直接创建新的就完事 - root = createNode(element); - }else if(root->element > element) { //和二叉搜索树一样,判断大小,该走哪边走哪边,直到找到对应插入位置 - root->left = insert(root->left, element); - if(getHeight(root->left) - getHeight(root->right) > 1) { //插入完成之后,需要计算平衡因子,看看是否失衡 - if(root->left->element > element) //接着需要判断一下是插入了左子树的左边还是右边,如果是左边那边说明是LL,如果是右边那说明是LR - root = rightRotation(root); //LL型得到左旋之后的结果,得到新的根结点 - else - root = leftRightRotation(root); //LR型得到先左旋再右旋之后的结果,得到新的根结点 - } - }else if(root->element < element){ - root->right = insert(root->right, element); - if(getHeight(root->left) - getHeight(root->right) < -1){ - if(root->right->element < element) - root = leftRotation(root); - else - root = rightLeftRightRotation(root); - } - } - //前面的操作完成之后记得更新一下树高度 - root->height = max(getHeight(root->left), getHeight(root->right)) + 1; - return root; //最后返回root到上一级 -} -``` - -这样,我们就完成了平衡二叉树的插入操作,当然删除操作比较类似,也是需要在删除之后判断是否平衡,如果不平衡同样需要进行旋转操作,这里就不做演示了。 - -### 红黑树 - -**注意:**本小节内容作为选学内容,不强制要求掌握。很多人都说红黑树难,其实就那几条规则,跟着我推一遍其实还是很简单的,当然前提是一定要把前面的平衡二叉树搞明白。 - -前面我们讲解了二叉平衡树,通过在插入结点时维护树的平衡,这样就不会出现极端情况使得整棵树的查找效率急剧降低了。但是这样是否开销太大了一点,因为一旦平衡因子的绝对值超过1那么就失衡,这样每插入一个结点,就有很大的概率会导致失衡,我们能否不这么严格,但同时也要在一定程度上保证平衡呢?这就要提到红黑树了。 - -在线动画网站:https://www.cs.usfca.edu/~galles/visualization/RedBlack.html - -红黑树也是二叉查找树的一种,它大概长这样,可以看到结点有红有黑: - -![image-20220815222810537](https://s2.loli.net/2022/08/15/t86B7sxvYeP9TiR.png) - -它并不像平衡二叉树那样严格要求高度差不能超过1,而是只需要满足五个规则即可,它的规则如下: - -- 规则1:每个结点可以是黑色或是红色。 -- 规则2:根结点一定是黑色。 -- 规则3:红色结点的父结点和子结点不能为红色,也就是说不能有两个连续的红色。 -- 规则4:所有的空结点都是黑色(空结点视为NIL,红黑树中是将空节点视为叶子结点) -- 规则5:每个结点到空节点(NIL)路径上出现的黑色结点的个数都相等。 - -它相比平衡二叉树,通过不严格平衡和改变颜色,就能在一定程度上减少旋转次数,这样的话对于整体性能是有一定提升的,只不过我们在插入结点时,就有点麻烦了,我们需要同时考虑变色和旋转这两个操作了,但是会比平衡二叉树更简单。 - -那么什么时候需要变色,什么时候需要旋转呢?我们通过一个简单例子来看看: - -![image-20220816104917851](https://s2.loli.net/2022/08/16/wIj5qnhxFAHcyG7.png) - -首先这棵红黑树只有一个根结点,因为根结点必须是黑色,所以说直接变成黑色。现在我们要插入一个新的结点了,所有新插入的结点,默认情况下都是红色: - -![image-20220816105119178](https://s2.loli.net/2022/08/16/yHRXgbsvOM27xLr.png) - -所以新来的结点7根据规则就直接放到11的左边就行了,然后注意7的左右两边都是NULL,那么默认都是黑色,这里就不画出来了。同样的,我们往右边也来一个: - -![image-20220816105553070](https://s2.loli.net/2022/08/16/kJiA71fQuKHnIdb.png) - -现在我们继续插入一个结点: - -![image-20220816105656320](https://s2.loli.net/2022/08/16/VEQLu5mb1tcTyzd.png) - -插入结点4之后,此时违反了红黑树的规则3,因为红色结点的父结点和子结点不能为红色,此时为了保持以红黑树的性质,我们就需要进行**颜色变换**才可以,那么怎么进行颜色变换呢?我们只需要直接将父结点和其兄弟结点同时修改为黑色(为啥兄弟结点也需要变成黑色?因为要满足性质5)然后将爷爷结点改成红色即可: - -![image-20220816113259643](https://s2.loli.net/2022/08/16/kuc1B3lqhNUwaSM.png) - -当然这里还需注意一下,因为爷爷结点正常情况会变成红色,相当于新来了个红色的,这时还得继续往上看有没有破坏红黑树的规则才可以,直到没有为止,比如这里就破坏了性质一,爷爷结点现在是根结点(不是根结点就不需要管了),必须是黑色,所以说还要给它改成黑色才算结束: - -![image-20220816113339344](https://s2.loli.net/2022/08/16/dpRX5DGsfWVwnQi.png) - -接着我们继续插入结点: - -![image-20220816113939172](https://s2.loli.net/2022/08/16/4ZAhv7R9YusI8q6.png) - -此时又来了一个插在4左边的结点,同样是连续红色,我们需要进行变色才可以讲解问题,但是我们发现,如果变色的话,那么从11开始到所有NIL结点经历的黑色结点数量就不对了: - -![image-20220816114245996](https://s2.loli.net/2022/08/16/n3M6Kfsb4jHtIci.png) - -所以说对于这种**父结点为红色,父结点的兄弟结点为黑色**(NIL视为黑色)的情况,变色无法解决问题了,那么我们只能考虑旋转了,旋转规则和我们之前讲解的平衡二叉树是一样的,这实际上是一种LL型失衡: - -![image-20220816115015892](https://s2.loli.net/2022/08/16/POTaBfosmQiceWk.png) - -同样的,如果遇到了LR型失衡,跟前面一样,先左旋在右旋,然后进行变色即可: - -![image-20220816115924938](https://s2.loli.net/2022/08/16/XqFr7hJwe38AakK.png) - -而RR型和RL型同理,这里就不进行演示了,可以看到,红黑树实际上也是通过颜色规则在进行旋转调整的,当然旋转和变色的操作顺序可以交换。所以,在插入时比较关键的判断点如下: - -* 如果整棵树为NULL,直接作为根结点,变成黑色。 -* 如果父结点是黑色,直接插入就完事。 -* 如果父结点为红色,且父结点的兄弟结点也是红色,直接变色即可(但是注意得继续往上看有没有破坏之前的结构) -* 如果父结点为红色,但父结点的兄弟结点为黑色,需要先根据情况(LL、RR、LR、RL)进行旋转,然后再变色。 - -在了解这些步骤之后,我们其实已经可以尝试去编写一棵红黑树出来了,当然代码太过复杂,这里就不演示了。其实红黑树难点并不在于如何构建和使用,而是在于,到底是怎么设计出来的,究竟要多么丰富的知识储备才能想到如此精妙的规则。 - -红黑树的发明者: - -> 红黑树(Red Black Tree) 是一种自平衡二叉查找树,是在[计算机](https://baike.baidu.com/item/计算机)科学中用到的一种[数据结构](https://baike.baidu.com/item/数据结构/1450),典型的用途是实现[关联数组](https://baike.baidu.com/item/关联数组/3317025)。 -> -> 红黑树是在1972年由[Rudolf Bayer](https://baike.baidu.com/item/Rudolf Bayer/3014716)发明的,当时被称为平衡二叉B树(symmetric binary B-trees)。后来,在1978年被 Leo J. Guibas 和 Robert Sedgewick 修改为如今的“红黑树”。 - -在了解了后面的B树之后,相信我们就能揭开这层神秘面纱了。 - -*** - -## 其他树结构 - -前面我们介绍了各种各样的二叉树,其实还是比较简单的。我们接着来看一下其他的一些树结构,这一部分我们只做了解即可。 - -### B树和B+树 - -前面我们介绍了多种多样的二叉树,有线索化二叉树,平衡二叉树等等,这些改造版二叉树无疑都是为了提高我们的程序运行效率而生的,我们接着来看一种同样为了提升效率的树结构。 - -这里首先介绍一下B树(Balance Tree),它是专门为磁盘数据读取设计的一种度为 m 的查找树(多用于数据库)它同样是一棵平衡树,但是不仅限于二叉了,之前我们介绍的这些的二叉树都是基于内存读取的优化,磁盘读取速度更慢,它同样需要优化,一棵度为4的(4阶)B树大概长这样: - -![image-20220817102503116](https://s2.loli.net/2022/08/17/lH9YBVIASQJe26d.png) - -第一眼看上去,感觉好像没啥头绪,不能发现啥规律,但是只要你仔细观察,你会发现,它和二叉查找树很相似,左边的一定比根节点小,右边的一定比根节点大,并且我们发现,每个结点现在可以保存多个值,每个结点可以连接多个子树,这些值两两组合划分了一些区间,比如60左边,一定是比60小的,60和80之间那么就是大于60小于80的值,以此类推,所以值有N个,就可以划分出N+1个区间,那么子树最多就可以有N+1个。它的详细规则如下: - -1. 树中每个结点最多含有m个孩子(m >= 2)比如上面就是m为4的4阶B树,最多有4个孩子。 -2. 除根结点和叶子结点外,其它每个结点至少有⌈m/2⌉个孩子,同理键值数量至少有⌈m/2⌉-1个。 -3. 若根结点不是叶子结点,则至少有2个孩子。 -4. 所有叶子结点都出现在同一层。 -5. 一个结点的包含多种信息(P0,K1,P1,K2,…,Kn,Pn),其中P为指向子树的指针,K为键值(关键字) - 1. Ki (i=1...n)为键值,也就是每个结点保存的值,且键值按顺序升序排序K(i-1)< Ki - 2. Pi为指向子树的指针,且指针Pi指向的子树中所有结点的键值均小于Ki,但都大于K(i-1) - 3. 键值的个数n必须满足: ⌈m/2⌉-1 <= n <= m-1 - -在线动画网站:https://www.cs.usfca.edu/~galles/visualization/BTree.html - -是不是感觉怎么要求这么多呢?我们通过感受一下B树的插入和删除就知道了,首先是B树的插入操作,这里我们以度为3的B树为例: - -![image-20220817105907362](https://s2.loli.net/2022/08/17/CqwaR1s2OyeIVLc.png) - -插入1之后,只有一个结点,我们接着插入一个2,插入元素满足以下规则: - -* 如果该节点上的元素数未满,则将新元素插入到该节点,并保持节点中元素的顺序。 - -所以,直接放进去就行,注意顺序: - -![image-20220817110243376](https://s2.loli.net/2022/08/17/HoamJkqwvP2ZlBb.png) - -接着我们再插入一个3进去,但是此时因为度为3,那么键值最多只能有两个,肯定是装不下了: - -* 如果该节点上的元素已满,则需要将该节点平均地分裂成两个节点: - 1. 首先从该节点中的所有元素和新元素中先出一个中位数作为**分割值**。 - 2. 小于中位数的元素作为左子树划分出去,大于中位数的元素作为右子树划分。 - 3. 分割值此时上升到父结点中,如果没有父结点,那么就创建一个新的(这里的上升不太好理解,一会我们推过去就明白了) - -所以,当3来了之后,直接进行分裂操作: - -![image-20220817110803123](https://s2.loli.net/2022/08/17/aEJxSUlY1t6nVWM.png) - -就像爱情一样,两个人的世界容不下第三者,如果来了第三者,那么最后的结果大概率就是各自分道扬镳。接着我们继续插入4、5看看会发生什么,注意插入还是按照小的走左边,大的走右边的原则,跟我们之前的二叉查找树是一样的: - -![image-20220817111405624](https://s2.loli.net/2022/08/17/Az8pmnsXvZaNl6q.png) - -此时4、5来到了右边,现在右边这个结点又被撑爆了,所以说需要按照上面的规则,继续进行分割: - -![image-20220817111556446](https://s2.loli.net/2022/08/17/odNZMzeUGWQObtA.png) - -可能各位看着有点奇怪,为啥变成这样了,首先3、4、5三个都分开了,然后4作为分割值,3、5变成两个独立的树,此时4需要上升到父结点,所以直接跑到上面去了,然后3和5出现在4的左右两边。注意这里不是向下划分,反而有点向上划分的意思。为什么不向下划分呢?因为要满足B树第四条规则:所有叶子结点都出现在同一层。 - -此时我们继续插入6、7,看看会发生什么情况: - -![image-20220817111943543](https://s2.loli.net/2022/08/17/U3ExLbOdD9tpAGW.png) - -此时右下角结点又被挤爆了,右下角真是多灾多难啊,那么依然按照我们之前的操作进行分裂: - -![image-20220817112213868](https://s2.loli.net/2022/08/17/nVFhBQoy7w195Sz.png) - -我们发现当新的分割值上升之后最上面的结点又被挤爆了,此时我们需要继续分裂: - -![image-20220817112401155](https://s2.loli.net/2022/08/17/kQJZDBbrgyHnac1.png) - -在2、4、6中寻找一个新的分割值,分裂后将其上升到新的父结点中,就像上图那样了。在了解了B树的插入操作之后,是不是有一点感受到这种结构带来的便捷了? - -我们再来看看B树的删除操作,这个要稍微麻烦一些,这里我们以一颗5阶B树为例,现在我们想删除16结点: - -![image-20220817114440027](https://s2.loli.net/2022/08/17/VsiQvCfEJ92oLch.png) - -删除后,依然满足B树的性质,所以说什么都不管用: - -![image-20220817114541675](https://s2.loli.net/2022/08/17/CzTIN2GeREP7lVU.png) - -此时我们接着去删除15结点: - -![image-20220817114722079](https://s2.loli.net/2022/08/17/ypYEDR7gIL4fZ8X.png) - -删除后,现在结点中只有14了,不满足B树的性质:除根结点和叶子结点外,其它每个结点至少有⌈m/2⌉个孩子,同理键值数量至少有⌈m/2⌉-1个,现在只有一个肯定是不行的。此时我们需向兄弟(注意只能找左右两边的兄弟)借一个过来: - -![image-20220817114956686](https://s2.loli.net/2022/08/17/dZVwpNlRzKxHerA.png) - -此时我们继续删掉17,但是兄弟已经没办法再借给我们一个元素了,此时只能采取方案二,合并兄弟节点与分割键。这里我们就合并左边的这个兄弟吧: - -![image-20220817120014656](https://s2.loli.net/2022/08/17/wxhF2bJUHlEMGXW.png) - -![image-20220817120058865](https://s2.loli.net/2022/08/17/Xp3l8AiDU6Bebwo.png) - -现在他们三个又合并回去了,这下总满足了吧?但是我们发现,父结点此时只有一个元素了,又出问题了。同样的,还是先去找兄弟结点借一个,但是兄弟结点也借不了了,此时继续采取我们的方案二,合并: - -![image-20220817120402123](https://s2.loli.net/2022/08/17/E2RzTW5XOJjHdQm.png) - -OK,这样才算是满足了B树的性质,现在我们继续删除4结点: - -![image-20220817120835776](https://s2.loli.net/2022/08/17/TBrynM7Ge2lfz31.png) - -这种情况会导致失去分割值,那么我们得找一个新的分割值才行,这里取左边最大的: - -![image-20220817121020793](https://s2.loli.net/2022/08/17/pLZJNEyzHAVjfU4.png) - -不过此时虽然解决了分割值的问题,但是新的问题来了,左边结点不满足性质了,元素数量低于限制,于是需要找兄弟结点借,但是没得借了,兄弟也没有多的可以借了所以被迫合并了: - -![image-20220817121250186](https://s2.loli.net/2022/08/17/jhT5SNFXwq9niYk.png) - -可以看到整个变换过程中,这颗B树所有子树的高度是一直维持在一个稳定状态的,查找效率能够持续保持。 - -删除操作可以总结为两大类: - -* 若删除的是叶子结点的中元素: - * 正常情况下直接删除。 - * 如果删除后,键值数小于最小值,那么需要找兄弟借一个。 - * 要是没得借了,直接跟兄弟结点、对应的分割值合并。 -* 若删除的是某个根结点中的元素: - * 一般情况会删掉一个分割值,删掉后需要重新从左右子树中找一个新分割值的拿上来。 - * 要是拿上来之后左右子树中出现键值数小于最小值的情况,那么就只能合并了。 -* 上述两个操作执行完后,还要继续往上看上面的结点是否依然满足性质,否则继续处理,直到稳定。 - -在了解了B树的相关操作之后,是不是感觉还是挺简单的,依然是动态维护树的平衡。正是得益于B树这种结点少,高度平衡且有序的性质,而硬盘IO速冻远低于内存,我们希望能够花费尽可能少的时间找到我们想要的数据,减少IO次数,B树就非常适合在硬盘上的保存数据,它的查找效率是非常高的。 - -**注意:以下内容为选学部分:** - -> 此时此刻,我们回想一下之前提到的红黑树,我们来看看它和B树有什么渊源,这是一棵很普通的红黑树: -> -> ![image-20220817123042186](https://s2.loli.net/2022/08/17/XorTHWdJEt24Zci.png) -> -> 此时我们将所有红色节点上移到与父结点同一高度, -> -> ![image-20220817123537220](https://s2.loli.net/2022/08/17/VkJmwZI8XFz9Yl2.png) -> -> 还是没看出来?没关系,我们来挨个画个框: -> -> ![image-20220817123455865](https://s2.loli.net/2022/08/17/2TgcNMdztpOEXk6.png) -> -> woc,这不就是B树吗?没错,**红黑树** 和 **4阶B树**(2-3-4树)具有等价性,其中黑色结点就是中间的(黑色结点一定是父结点),红色结点分别位于两边,通过将黑色结点与它的红色子节点融合在一起,形成1个B树节点,最后就像这样: -> -> ![image-20220817153152790](https://s2.loli.net/2022/08/17/MJiErSB4p856mjd.png) -> -> 你会发现,红黑树的黑色节点个数总是与4阶B树的节点数相等。我们可以对比一下之前的红黑树插入和4阶B树的插入,比如现在我们想要插入一个新的14结点进来: -> -> ![image-20220817153955759](https://s2.loli.net/2022/08/17/mNS8zRofZCM6quE.png) -> -> 经过变色,最后得到如下的红黑树,此时又出现两个红色结点连续,因为父结点的兄弟结点依然是红色,继续变色: -> -> ![image-20220817154655210](https://s2.loli.net/2022/08/17/DE5UTIkbdBvAoL9.png) -> -> 最后因为根结点必须是黑色,所以说将60变为黑色,这样就插入成功了: -> -> ![image-20220817154751660](https://s2.loli.net/2022/08/17/4nqCNJeFxQbmRGy.png) -> -> 我们再来看看与其等价的B树插入14后会怎么样: -> -> ![image-20220817154838567](https://s2.loli.net/2022/08/17/ltno5TuiHAb3QNj.png) -> -> 由于B树的左边被挤爆了,所以说需要分裂,因为是偶数个,需要选择中间偏右的那个数作为分割值,也就是25: -> -> ![image-20220817160036666](https://s2.loli.net/2022/08/17/jZ4EvWynm5aQelq.png) -> -> 分裂后,分割值上升,又把父结点给挤爆了,所以说需要继续分裂: -> -> ![image-20220817160244020](https://s2.loli.net/2022/08/17/7SRHOMucikbnml3.png) -> -> 现在就变成了这样,我们来对比一下红黑树: -> -> ![image-20220817160427011](https://s2.loli.net/2022/08/17/arxhpI1ytvq7wO9.png) -> -> 不能说很像,只能说是一模一样啊。为什么呢?明明这两种树是不同的规则啊,为什么会出现等价的情况呢? -> -> * B树叶节点等深实际上体现在红黑树中为任一叶节点到达根节点的路径中,黑色路径所占的长度是相等的,因为黑色结点就是B树的结点分割值。 -> * B树节点的键值数量不能超过N实际上体现在红黑树约定相邻红色结点接最多2条,也就是说不可能出现B树中元素超过3的情况,因为是4阶B树。 -> -> 所以说红黑树跟4阶B树是有一定渊源的,甚至可以说它就是4阶B树的变体。 - -前面我们介绍了B树,现在我们就可以利用B树来高效存储数据了,当然我们还可以让它的查找效率更高。这里我们就要提到B+树了,B+树是B树的一种变体,有着比B树更高的查询性能。 - -1. 有k个子树的中间结点包含有k个元素(B树中是k-1个元素),每个元素不保存数据,只用来索引,所有数据(卫星数据,就是具体需要保存的内容)都保存在叶子结点。 -2. 所有的叶子结点中包含了全部元素的信息,及指向含这些元素记录的指针,且叶子结点按照从小到大的顺序连接。 -3. 所有的根结点元素都同时存在于子结点中,在子节点元素中是最大(或最小)元素。 - -我们来看看一棵B+树长啥样: - -![image-20220817163343975](https://s2.loli.net/2022/08/17/C4utSmNvKPAaZ35.png) - -其中最后一层形成了一个有序链表,在我们需要顺序查找时,提供了极大的帮助。可以看到现在除了最后一层之外,其他结点中存放的值仅仅充当了一个指路人的角色,来告诉你你需要的数据在哪一边,比如根节点有10和18,因为这里是取得最大值,那么整棵树最大的元素就是18了,我们现在需要寻找一个小于18大于10的数,就可以走右边去查找。而具体的数据会放到最下面的叶子结点中,比如数据库就是具体的某一行数据(卫星数据)存放在最下面: - -![image-20220817163816562](https://s2.loli.net/2022/08/17/pW5SiDqmNY2PXfZ.png) - -当然,目前可能你还没有接触过数据库,在以后的学习中,你一定会接触到它的,到时你就会发现新世界。 - -它不像B树那样,B树并不是只有最后一行会存储卫星数据,此时比较凌乱。因为只有最后一行存储卫星数据,使用B+树,同样大小的磁盘页可以容纳更多的节点元素,这就意味着,数据量相同的情况下B+树比B树高度更低,减小磁盘IO的次数。其次,B+树的查询必须最终查找到叶子节点,而B树做的是值匹配,到达结点之后并不一定能够匹配成功,所以B树的查找性能并不稳定,最好的情况是只查根节点即可,而最坏的情况则需要查到叶子节点,但是B+树每一次查找都是稳定的,因为一定在叶子结点。 - -并且得益于最后一行的链表结构,B+树在做范围查询时性能突出。很多数据库都在采用B+树作为底层数据结构,比如MySQL就默认选择B+Tree作为索引的存储数据结构。 - -至此,有关B树和B+树相关内容,就到这里。 - -### 哈夫曼树 - -最后我们来介绍一个比较重要的的树形结构,在开篇之前,我想问下,各位了解文件压缩吗?它是怎么做到的呢?我们都会在这一节进行探讨。 - -> 给定N个权值作为N个叶子结点,构造一棵二叉树,若该树的带权路径长度达到最小,称这样的二叉树为最优二叉树,也称为哈夫曼树(Huffman Tree) - -乍一看好像没看懂,啥叫带权路径长度达到最小?就是树中所有的叶结点的权值乘上其到根结点根结点的路径长度(若根结点为0层,叶结点到根结点的路径长度为叶结点的层数) - -![image-20220817170310064](https://s2.loli.net/2022/08/17/goKnFtErpiNQebU.png) - -这里我们分别将叶子结点ABCD都赋予一个权值,我们来尝试计算一下,计算公式如下: -$$ -WPL = \sum_{i=1}^{n} (value(i) \times depth(i)) -$$ -那么左右两边的计算结果为: - -* 左图: $WPL=5\times2+7\times2+2\times2+13\times2=54$ -* 右图:$WPL=5\times3+2\times3+7\times2+13\times1=48$ - -通过计算结果可知,右图的带权路径长度最小,实际上右图是一棵哈夫曼树。 - -那么现在给了我们这些带权的叶子结点,我们怎么去构建一颗哈夫曼树呢?首先我们可以将这些结点视为4棵树,他们共同构成了一片森林: - -![image-20220817171759738](https://s2.loli.net/2022/08/17/V1E4tZsnGbWFzjo.png) - -首先我们选择两棵权值最小的树作为一颗新的树的左右子树,左右顺序不重要(因为哈夫曼编码不唯一,后面会说),得到的树根结点权值为这两个结点之和: - -![image-20220817172343786](https://s2.loli.net/2022/08/17/ZCyj1PVwsqiWz4e.png) - -接着,我们需要将这这棵树放回到森林中,重复上面的操作,继续选择两个最小的出来组成一颗新的树,此时得到: - -![image-20220817172640686](https://s2.loli.net/2022/08/17/G5EyArvMhJ9CQNS.png) - -继续重复上述操作,直到森林里面只剩下一棵树为止: - -![image-20220817172737480](https://s2.loli.net/2022/08/17/ywuA6pRPrboE51S.png) - -这样,我们就得到了一棵哈夫曼树,因为只要保证越大的值越靠近根结点,那么出来的一定是哈夫曼树。所以,我们辛辛苦苦把这棵树构造出来干嘛呢?实际上哈夫曼树的一个比较重要应用就是对数据进行压缩,它是现代压缩算法的基础,我们常常可以看到网上很多文件都是以压缩包(.zip、.7z、.rar等格式)形式存在的,我们将文件压缩之后。 - -比如这一堆字符串:ABCABCD,现在我们想要将其进行压缩然后保存到硬盘上,此时就可以使用哈夫曼编码。那么怎么对这些数据进行压缩呢?这里我们就可以采用刚刚构建好的哈夫曼树,我们需要先对其进行标注: - -![image-20220817173559604](https://s2.loli.net/2022/08/17/oRuOayXEKFkPs3d.png) - -向左走是0,向右走是1,比如现在我们要求出A的哈夫曼编码,那么就是根结点到A整条路径上的值拼接: - -* A:110 -* B:0 -* C:111 -* D:10 - -这些编码看起来就像二进制的一样,也便于我们计算机的数据传输和保存,现在我们要对上面的这个字符串进行压缩,那么只需要将其中的每一个字符翻译为对应编码就行了: - -* ABCABCD = 110 0 111 110 0 111 10 - -这样我们就得到了一堆压缩之后的数据了。那怎么解码回去呢,也很简单,只需要对照着写回去就行了: - -* 110 0 111 110 0 111 10 = ABCABCD - -我们来尝试编写一下代码实现一下哈夫曼树的构建和哈夫曼编码的获取把,因为构建哈夫曼树需要选取最小的两个结点,这里需要使用到优先级队列。 - -优先级队列与普通队列不同,它允许VIP插队(权值越大的元素优先排到前面去),当然出队还是一律从队首出来。 - -![image-20220817174835425](https://s2.loli.net/2022/08/17/xySEK5OZ8Q3IbNz.png) - -比如一开始4和9排在队列中,这时又来了个7,那么由于7比4大,所以说可以插队,直接排到4的前面去,但是由于9比7大,所以说不能再往前插队了: - -![image-20220817174921980](https://s2.loli.net/2022/08/17/bv4cD8GTgo2qPEQ.png) - -这就是优先级队列,VIP插队机制,要实现这样的优先级队列,我们只需要修改一下入队操作即可: - -```c -_Bool initQueue(LinkedQueue queue){ - LNode node = malloc(sizeof(struct LNode)); - if(node == NULL) return 0; - queue->front = queue->rear = node; - node->next = NULL; //因为下面用到了判断结点的下一个为NULL,所以说记得默认设定为NULL - return 1; -} - -_Bool offerQueue(LinkedQueue queue, T element){ - LNode node = malloc(sizeof(struct LNode)); - if(node == NULL) return 0; - node->element = element; - node->next = NULL; //因为下面用到了判断结点的下一个为NULL,所以说记得默认设定为NULL - LNode pre = queue->front; //我们从头结点开始往后挨个看,直到找到第一个小于当前值的结点,或者到头为止 - while (pre->next && pre->next->element >= element) - pre = pre->next; - if(pre == queue->rear) { //如果说找到的位置已经是最后了,那么直接插入就行,这里跟之前是一样的 - queue->rear->next = node; - queue->rear = node; - } else { //否则开启VIP模式,直接插队 - node->next = pre->next; - pre->next = node; - } - return 1; -} -``` - -我们来测试一下吧: - -```c -int main(){ - struct Queue queue; - initQueue(&queue); - - offerQueue(&queue, 9); - offerQueue(&queue, 4); - offerQueue(&queue, 7); - offerQueue(&queue, 3); - offerQueue(&queue, 13); - - printQueue(&queue); -} -``` - -![image-20220817180127650](https://s2.loli.net/2022/08/17/cw6QCUSgDjotKbl.png) - -这样我们就编写好了一个优先级队列,然后就可以开始准备构建哈夫曼树了: - -```c -typedef char E; - -typedef struct TreeNode { - E element; - struct TreeNode * left; - struct TreeNode * right; - int value; //存放权值 -} * Node; -``` - -首先按照我们前面的例子,构建出这四个带权值的结点: - -```c -Node createNode(E element, int value){ //创建一个结点 - Node node = malloc(sizeof(struct TreeNode)); - node->element = element; - node->left = node->right = NULL; - node->value = value; - return node; -} -``` - -```c -_Bool offerQueue(LinkedQueue queue, T element){ - LNode node = malloc(sizeof(struct LNode)); - if(node == NULL) return 0; - node->element = element; - node->next = NULL; - LNode pre = queue->front; - while (pre->next && pre->next->element->value <= element->value) //注意这里改成权重的比较,符号改成小于 - pre = pre->next; - if(pre == queue->rear) { - queue->rear->next = node; - queue->rear = node; - } else { - node->next = pre->next; - pre->next = node; - } - return 1; -} -``` - -现在我们来测试一下吧: - -```c -int main(){ - struct Queue queue; - initQueue(&queue); - - offerQueue(&queue, createNode('A', 5)); - offerQueue(&queue, createNode('B', 16)); - offerQueue(&queue, createNode('C', 8)); - offerQueue(&queue, createNode('D', 13)); - - printQueue(&queue); -} -``` - -![image-20220817180820954](https://s2.loli.net/2022/08/17/IU9RYEVl7GytZmQ.png) - -已经是按照权重顺序在排队了,接着我们就可以开始构建哈夫曼树了: - -```c -int main(){ - struct Queue queue; - initQueue(&queue); - - offerQueue(&queue, createNode('A', 5)); - offerQueue(&queue, createNode('B', 16)); - offerQueue(&queue, createNode('C', 8)); - offerQueue(&queue, createNode('D', 13)); - - while (queue.front->next != queue.rear) { //如果front的下一个就是rear那么说明队列中只有一个元素了 - Node left = pollQueue(&queue); - Node right = pollQueue(&queue); - Node node = createNode(' ', left->value + right->value); //创建新的根结点 - node->left = left; - node->right = right; - offerQueue(&queue, node); //最后将构建好的这棵树入队 - } - - Node root = pollQueue(&queue); //最后出来的就是哈夫曼树的根结点了 -} -``` - -现在得到哈夫曼树之后,我们就可以对这些字符进行编码了,当然注意我们这里面只有ABCD这几种字符: - -```c -char * encode(Node root, E e){ - if(root == NULL) return NULL; //为NULL肯定就是没找到 - if(root->element == e) return ""; //如果找到了就返回一个空串 - char * str = encode(root->left, e); //先去左边找 - char * s = malloc(sizeof(char) * 10); - if(str != NULL) { - s[0] = '0'; - str = strcat(s, str); //如果左边找到了,那么就把左边的已经拼好的字符串拼接到当前的后面 - } else { //左边不行那再看看右边 - str = encode(root->right, e); - if(str != NULL) { - s[0] = '1'; - str = strcat(s, str); //如果右边找到了,那么就把右边的已经拼好的字符串拼接到当前的后面 - } - } - return str; //最后返回操作好的字符串给上一级 -} - -void printEncode(Node root, E e){ - printf("%c 的编码为:%s", e, encode(root, e)); //编码的结果就是了 - putchar('\n'); -} -``` - -最后测试一下吧: - -```c -int main(){ - struct Queue queue; - initQueue(&queue); - - ... - - Node root = pollQueue(&queue); - printEncode(root, 'A'); - printEncode(root, 'B'); - printEncode(root, 'C'); - printEncode(root, 'D'); -} -``` - -成功得到对应的编码: - -![image-20220817184746630](https://s2.loli.net/2022/08/17/zx2cXns73yQThaV.png) - -### 堆和优先级队列 - -前面我们在讲解哈夫曼树时了解了优先级队列,它提供一种可插队的机制,允许权值大的结点排到前面去,但是出队顺序还是从队首依次出队。我们通过对前面的队列数据结构的插入操作进行改造,实现了优先级队列。 - -这节课我们接着来了解一下**堆**(Heap)它同样可以实现优先级队列。 - -首先必须是一棵完全二叉树,树中父亲都比孩子小的我们称为**小根堆**(小顶堆),树中父亲都比孩子大则是**大根堆**(注意不要跟二叉查找树搞混了,二叉查找树是左小右大,而堆只要是孩子一定小或者大),它是一颗具有特殊性质的完全二叉树。比如下面就是一个典型的大根堆: - -![image-20220818104754776](https://s2.loli.net/2022/08/18/1ULKRiAeZcI2hJm.png) - -因为完全二叉树比较适合使用数组才存储(因为是按序的)所以说一般堆都是以数组形式存放: - -![image-20220818110224673](https://s2.loli.net/2022/08/18/XpYVN2gslOfWLSr.png) - -那么它是怎么运作的呢?比如现在我们想要往堆中插入一个新的元素8,那么: - -![image-20220818110450863](https://s2.loli.net/2022/08/18/mcq2wjLvxHUu6R7.png) - - 因为是一棵完全二叉树,那么必须按照顺序,继续在当前这一行从左往右插入新的结点,其实就相当于在数组的后面继续加一个新的进来,是一样的。但是因为要满足大顶堆的性质,所以此时8加入之后,破坏了规则,我们需要进行对应的调整(堆化),很简单,我们只需要将其与父结点交换即可: - -![image-20220818110835798](https://s2.loli.net/2022/08/18/T187nAaRBV9jJed.png) - -同样的,数组的形式的话,我们就行先计算出它的父结点,然后进行交换即可: - -![image-20220818111156209](https://s2.loli.net/2022/08/18/tp81Tlr6LzFeaXQ.png) - -当然,还没完,我们还需要继续向上比较,直到稳定为止,此时7依然是小于8的,所以说需要继续交换: - -![image-20220818111311322](https://s2.loli.net/2022/08/18/FP5LhdDZ9zVBYfl.png) - -现在满足性质了,堆化结束,可以看到最大的元素被排到了最前面,这不就是我们前面的优先级队列吗。 - -现在我们来试试看删除队首元素,也就相当于出队操作,删除最顶上的元素: - -![image-20220818111840303](https://s2.loli.net/2022/08/18/XxivcLFwebrUKf2.png) - -现在需要删除最顶上的元素但是我们需要保证删除之后依然是一棵完全二叉树,所以说我们先把排在最后面的拿上来顶替一下: - -![image-20220818111959046](https://s2.loli.net/2022/08/18/MmtNHQla3zej6FC.png) - -![image-20220818112109066](https://s2.loli.net/2022/08/18/OWGiYxKb71o249T.png) - -接着我们需要按照与插入相反的方向,从上往下进行堆化操作,规则是一样的,遇到大的就交换,直到不是为止: - -![image-20220818112222696](https://s2.loli.net/2022/08/18/BqTkxDov8AXtwCZ.png) - -这样,我们发现,即使完成了出队操作,依然是最大的元素排在队首,并且整棵树依然是一棵完全二叉树。 - -按照上面的操作,我们来编写一下代码吧,这里还是以大顶堆为例: - -```c -typedef int E; -typedef struct MaxHeap { - E * arr; - int size; - int capacity; -} * Heap; - -_Bool initHeap(Heap heap){ //初始化都是老套路了,不多说了 - heap->size = 0; - heap->capacity = 10; - heap->arr = malloc(sizeof (E) * heap->capacity); - return heap->arr != NULL; -} - -int main(){ - struct MaxHeap heap; - initHeap(&heap); -} -``` - -接着就是插入操作,首先还是需要判断是否已满: - -```c -_Bool insert(Heap heap, E element){ - if(heap->size == heap->capacity) return 0; //满了就不处理了,主要懒得写扩容了 - int index = ++heap->size; //先计算出要插入的位置,注意要先自增,因为是从1开始的 - //然后开始向上堆化,直到符合规则为止 - while (index > 1 && element > heap->arr[index / 2]) { - heap->arr[index] = heap->arr[index / 2]; - index /= 2; - } - //现在得到的index就是最终的位置了 - heap->arr[index] = element; - return 1; -} -``` - -我们来测试一下吧: - -```c -void printHeap(Heap heap){ - for (int i = 1; i <= heap->size; ++i) - printf("%d ", heap->arr[i]); -} - -int main(){ - struct MaxHeap heap; - initHeap(&heap); - insert(&heap, 5); - insert(&heap, 2); - insert(&heap, 3); - insert(&heap, 7); - insert(&heap, 6); - - printHeap(&heap); -} -``` - -最后结果为: - -![image-20220818120554099](https://s2.loli.net/2022/08/18/bFS9KEPNxRdnYas.png) - -插入完成之后,我们接着来写一下删除操作,删除操作实际上就是出队的操作: - -```c -E delete(Heap heap){ - E max = heap->arr[1], e = heap->arr[heap->size--]; - int index = 1; - while (index * 2 <= heap->size) { //跟上面一样,开找,只不过是从上往下找 - int child = index * 2; //先找到左孩子 - //看看右孩子和左孩子哪个大,先选一个大的出来 - if(child < heap->size && heap->arr[child] < heap->arr[child + 1]) - child += 1; - if(e >= heap->arr[child]) break; //如果子结点都不大于新结点,那么说明就是这个位置,结束就行了 - else heap->arr[index] = heap->arr[child]; //否则直接堆化,换上去 - index = child; //最后更新一下index到下面去 - } - heap->arr[index] = e; //找到合适位置后,放进去就行了 - return max; -} -``` - -最后我们来测试一下吧: - -```c -int main(){ - struct MaxHeap heap; - initHeap(&heap); - ... - for (int i = 0; i < 5; ++i) { - printf("%d ", delete(&heap)); - } -} -``` - -![image-20220818120633714](https://s2.loli.net/2022/08/18/x8YDojfnp2yBqvA.png) - -可以看到结果就是优先级队列的出队结果,这样,我们就编写好了大顶堆的插入和删除操作了。 - -当然,堆在排序上也有着非常方便的地方,在后面的排序算法篇中,我们还会再次说起它。 - -至此,有关树形结构篇的内容,我们就全部讲解完毕了,请务必认真掌握前面的二叉树和高级二叉树结构,这些都是重点内容,下一章我们将继续探讨**散列表**。 - -*** - -## 算法实战 - -二叉树相关的算法实战基本都是与递归相关的,因为它实在是太适合用分治算法了! - -### (简单)二叉查找树的范围和 - -本题来自LeetCode:[938. 二叉搜索树的范围和](https://leetcode.cn/problems/range-sum-of-bst/) - -给定**二叉搜索树**的根结点 root,返回值位于范围 [low, high] 之间的所有结点的值的和。 - -示例 1: - -> ![img](https://assets.leetcode.com/uploads/2020/11/05/bst1.jpg) -> -> 输入:root = [10,5,15,3,7,null,18], low = 7, high = 15 (注意力扣上的输入案例写的是层序序列,含空节点) -> 输出:32 - -示例 2: - -> ![img](https://assets.leetcode.com/uploads/2020/11/05/bst2.jpg) -> -> 输入:root = [10,5,15,3,7,13,18,1,null,6], low = 6, high = 10 -> 输出:23 - -这道题其实就是考察我们对于二叉查找树的理解,利用二叉查找树的性质,这道题其实很简单,只需要通过递归分治就可以解决了。 - -代码如下: - -```c -int rangeSumBST(struct TreeNode* root, int low, int high){ - if(root == NULL) return 0; - if(root->val > high) //如果最大的值都比当前结点值小,那么肯定在左边才能找到 - return rangeSumBST(root->left, low, high); - else if(root->val < low) //如果最小值都比当前结点大,那么肯定在右边才能找到 - return rangeSumBST(root->right, low, high); - else - //这种情况肯定是在范围内了,将当前结点值加上左右的,再返回 - return root->val + rangeSumBST(root->right, low, high) + rangeSumBST(root->left, low, high); -} -``` - -这种问题比较简单,直接四行就解决了。 - -*** - -### (中等)重建二叉树 - -本题来自LeetCode:[剑指 Offer 07. 重建二叉树](https://leetcode.cn/problems/zhong-jian-er-cha-shu-lcof/) - -输入某二叉树的前序遍历和中序遍历的结果,请构建该二叉树并返回其根节点。 - -假设输入的前序遍历和中序遍历的结果中都不含重复的数字。 - -示例 1: - -> ![img](https://assets.leetcode.com/uploads/2021/02/19/tree.jpg) -> -> Input: preorder = [3,9,20,15,7], inorder = [9,3,15,20,7] -> Output: [3,9,20,null,null,15,7] - -示例 2: - -> Input: preorder = [-1], inorder = [-1] -> Output: [-1] - -实际上这道题就是我们前面练习题的思路,现在给到我们的是前序和中序遍历的结果,我们只需要像之前一样逐步推导即可。 - -在中序遍历序列中找到根节点的位置后,这个问题就很好解决了,大致思路如下: - -1. 由于前序遍历首元素为根节点值,首先可以得到根节点值。 -2. 在中序遍历序列中通过根节点的值,寻找根节点的位置。 -3. 将左右两边的序列分割开来,并重构为根节点的左右子树。(递归分治) -4. 在新的序列中,重复上述步骤,通过前序遍历再次找到当前子树的根节点,再次进行分割。 -5. 直到分割到仅剩下一个结点时,开始回溯,从而完成整棵二叉树的重建。 - -解题代码如下: - -```c -struct TreeNode * createNode(int val){ //这个就是单纯拿来创建结点的函数 - struct TreeNode * node = malloc(sizeof(struct TreeNode)); - node->left = node->right = NULL; - node->val = val; - return node; -} - -//核心递归分治实现 -struct TreeNode* buildTreeCore(int * preorder, int * inorder, int start, int end, int index){ - if(start > end) return NULL; //如果都超出范围了,肯定不行 - if(start == end) return createNode(preorder[index]); //如果已经到头了,那么直接创建结点返回即可 - struct TreeNode * node = createNode(preorder[index]); //先从前序遍历中找到当前子树的根结点值,然后创建对应的结点 - int pos = 0; - while (inorder[pos] != preorder[index]) pos++; //找到中序的对应位置,从这个位置开始左右划分 - node->left = buildTreeCore(preorder, inorder, start, pos - 1, index+1); - //当前结点的左子树按照同样的方式建立 - //因为前序遍历的下一个结点就是左子树的根结点,所以说这里给index+1 - node->right = buildTreeCore(preorder, inorder, pos+1, end, index+(pos-start)+1); - //当前结点的右子树按照同样的方式建立 - //最后一个index需要先跳过左子树的所有结点,才是右子树的根结点,所以说这里加了个pos-start,就是中序划分出来,左边有多少就减去多少 - return node; //向上一级返回当前结点 -} - -struct TreeNode* buildTree(int* preorder, int preorderSize, int* inorder, int inorderSize){ - return buildTreeCore(preorder, inorder, 0, preorderSize - 1, 0); - //这里传入了前序和中序序列,并且通过start和end指定当前中序序列的处理范围,最后的一个index是前序遍历的对应头结点位置 -} -``` - -*** - -### (中等)验证二叉搜索树 - -本题来自LeetCode:[98. 验证二叉搜索树](https://leetcode.cn/problems/validate-binary-search-tree/)(先说,这题老六行为过多,全站通过率只有36.5%,但是题目本身很简单) - -给你一个二叉树的根节点 root ,判断其是否是一个有效的二叉搜索树。 - -有效 二叉搜索树定义如下: - -节点的左子树只包含 小于 当前节点的数。 -节点的右子树只包含 大于 当前节点的数。 -所有左子树和右子树自身必须也是二叉搜索树。 - -示例 1: - -> ![img](https://assets.leetcode.com/uploads/2020/12/01/tree1.jpg) -> -> 输入:root = [2,1,3] -> 输出:true - -示例 2: - -> ![img](https://assets.leetcode.com/uploads/2020/12/01/tree2.jpg) -> -> 输入:root = [5,1,4,null,null,3,6] -> 输出:false -> 解释:根节点的值是 5 ,但是右子节点的值是 4 。 - -这种题看起来好像还挺简单的,我们可以很快地写出代码: - -```c -bool isValidBST(struct TreeNode* root){ - if(root == NULL) return true; //到头了就直接返回真 - if(root->left != NULL && root->left->val >= root->val) return false; //如果左边不是空,并且左边还比当前结点值小的话,那肯定不是了 - if(root->right != NULL && root->right->val <= root->val) return false; //同上 - return isValidBST(root->left) && isValidBST(root->right); //接着向下走继续判断左右两边子树,必须同时为真才是真 -} -``` - -然后直接上力扣测试,嗯,没问题,提交,这把必过!于是光速打脸: - -![image-20220817224437688](https://s2.loli.net/2022/08/17/EQdvDtlnSgU7kWC.png) - -不可能啊,我们的逻辑判断没有问题的,我们的算法不可能被卡的啊?(这跟我当时打ACM一样的感觉,我这天衣无缝的算法不可能错的啊,哪个老六测试用例给我卡了)这其实是因为我们没有考虑到右子树中左子树比根结点值还要小的情况: - -![image-20220817224830911](https://s2.loli.net/2022/08/17/AjU1G2nXytRCKoW.png) - -虽然这样错的很明显,但是按照我们上面的算法,这种情况确实也会算作真。所以说我们需要改进一下,对其上界和下界进行限定,不允许出现这种低级问题: - -```c -bool isValid(struct TreeNode* root, long min, long max){ //这里上界和下界用long表示,因为它的范围给到整个int,真是个老六 - if(root == NULL) return true; - //这里还需要判断是否正常高于下界 - if(root->left != NULL && (root->left->val >= root->val || root->left->val <= min)) - return false; - //这里还需判断一下是否正常低于上界 - if(root->right != NULL && (root->right->val <= root->val || root->right->val >= max)) - return false; - return isValid(root->left, min, root->val) && isValid(root->right, root->val, max); - //注意往左走更新上界,往右走更新下界 -} - -bool isValidBST(struct TreeNode* root){ - return isValid(root, -2147483649, 2147483648); //下界刚好比int少1,上界刚好比int多1 -} -``` - -这样就没问题了。 - -*** - -### (中等)求根到叶数字之和 - -本题来自LeetCode:[129. 求根节点到叶节点数字之和](https://leetcode.cn/problems/sum-root-to-leaf-numbers/) - -给你一个二叉树的根节点 root ,树中每个节点都存放有一个 0 到 9 之间的数字。 -每条从根节点到叶节点的路径都代表一个数字: - -例如,从根节点到叶节点的路径 1 -> 2 -> 3 表示数字 123 。 -计算从根节点到叶节点生成的 所有数字之和 。 - -叶节点 是指没有子节点的节点。 - -示例 1: - -> ![img](https://assets.leetcode.com/uploads/2021/02/19/num1tree.jpg) -> -> 输入:root = [1,2,3] -> 输出:25 -> 解释: -> 从根到叶子节点路径 1->2 代表数字 12 -> 从根到叶子节点路径 1->3 代表数字 13 -> 因此,数字总和 = 12 + 13 = 25 - -示例 2: - -> ![img](https://assets.leetcode.com/uploads/2021/02/19/num2tree.jpg) -> -> 输入:root = [4,9,0,5,1] -> 输出:1026 -> 解释: -> 从根到叶子节点路径 4->9->5 代表数字 495 -> 从根到叶子节点路径 4->9->1 代表数字 491 -> 从根到叶子节点路径 4->0 代表数字 40 -> 因此,数字总和 = 495 + 491 + 40 = 1026 - -这道题其实也比较简单,直接从上向下传递当前路径上已经组装好的值即可,到底时返回最终的组装结果: - -```c -int sumNumbersImpl(struct TreeNode * root, int parent){ - if(root == NULL) return 0; //如果到头了,直接返回0 - int sum = root->val + parent * 10; //因为是依次向后拼接,所以说直接将之前的值x10然后加上当前值即可 - if(!root->left && !root->right) //如果是叶子结点,那么直接返回结果 - return sum; - //否则按照同样的方式将左右的结果加起来 - return sumNumbersImpl(root->left, sum) + sumNumbersImpl(root->right, sum); -} - -int sumNumbers(struct TreeNode* root){ - return sumNumbersImpl(root, 0); -} -``` - -*** - -### (困难)结点之和的最大路径 - -本题来自LeetCode:[剑指 Offer II 051. 节点之和最大的路径](https://leetcode.cn/problems/jC7MId/)(这是一道Hard难度的题目,但是其实还好) - -路径 被定义为一条从树中任意节点出发,沿父节点-子节点连接,达到任意节点的序列。同一个节点在一条路径序列中 至多出现一次 。该路径 至少包含一个 节点,且不一定经过根节点。 - -路径和 是路径中各节点值的总和。 - -给定一个二叉树的根节点 root ,返回其 最大路径和,即所有路径上节点值之和的最大值。 - -示例 1: - -> ![img](https://assets.leetcode.com/uploads/2020/10/13/exx1.jpg) -> -> 输入:root = [1,2,3] -> 输出:6 -> 解释:最优路径是 2 -> 1 -> 3 ,路径和为 2 + 1 + 3 = 6 - -示例 2: - -> ![img](https://assets.leetcode.com/uploads/2020/10/13/exx2.jpg) -> -> 输入:root = [-10,9,20,null,null,15,7] -> 输出:42 -> 解释:最优路径是 15 -> 20 -> 7 ,路径和为 15 + 20 + 7 = 42 - -首先,我们要知道,路径有很多种可能,要么从上面下来,要么从左边上来往右边走,要么只走右边,要么只走左边...我们需要寻找一个比较好的方法在这么多种可能性之间选择出最好的那一个。 - -```c -int result = -2147483648; //使用一个全局变量来存储一下当前的最大值 -int max(int a, int b){ //不想多说了 - return a > b ? a : b; -} - -int maxValue(struct TreeNode* root){ - if(root == NULL) return 0; - //先把左右两边走或是不走的情况计算一下,取出值最大的情况 - int leftMax = max(maxValue(root->left), 0); - int rightMax = max(maxValue(root->right), 0); - //因为要么只走左边,要么只走右边,要么左右都走,所以说我们计算一下最大情况下的结果 - int maxTmp = leftMax + rightMax + root->val; - result = max(maxTmp, result); //更新一下最大值 - //然后就是从上面下来的情况了,从上面下来要么左要么右,此时我们只需要返回左右最大的一个就行了 - return max(leftMax, rightMax) + root->val; //注意还要加上当前结点的值,因为肯定要经过当前结点 -} - -int maxPathSum(struct TreeNode* root){ - maxValue(root); - return result; //最后返回完事之后最终得到的最大值 -} -``` - -这样,我们就成功解决了这种问题。 diff --git a/青空笔记/数据结构笔记/数据结构与算法(五).md b/青空笔记/数据结构笔记/数据结构与算法(五).md deleted file mode 100644 index 860a8b4..0000000 --- a/青空笔记/数据结构笔记/数据结构与算法(五).md +++ /dev/null @@ -1,933 +0,0 @@ -![image-20220821224607720](https://s2.loli.net/2022/08/21/cTmg9sCrzIuHhBb.png) - -# 排序算法篇 - -恭喜各位小伙伴来到最后一部分:**排序算法篇**,数据结构与算法的学习也接近尾声了,坚持就是胜利啊! - -一个数组中的数据原本是凌乱的,但是由于需要,我们需要使其有序排列,要实现对数组进行排序我们之前已经在**C语言程序设计篇**中讲解过冒泡排序和快速排序(选学),而这一部分,我们将继续讲解更多种类型的排序算法。 - -在开始之前,我们还是从冒泡排序开始回顾。 - -## 基础排序 - -### 冒泡排序 - -冒泡排序在C语言程序设计篇已经讲解过了,冒泡排序的核心就是交换,通过不断地进行交换,一点一点将大的元素推向一端,每一轮都会有一个最大的元素排到对应的位置上,最后形成有序。算法演示网站:https://visualgo.net/zh/sorting?slide=2-2 - -设数组长度为N,详细过程为: - -* 共进行N轮排序。 -* 每一轮排序从数组的最左边开始,两两元素进行比较,如果左边元素大于右边的元素,那么就交换两个元素的位置,否则不变。 -* 每轮排序都会将剩余元素中最大的一个推到最右边,下次排序就不再考虑这些已经在对应位置的元素。 - -比如下面的数组: - -![image-20220904212453328](https://s2.loli.net/2022/09/04/BYOvgd3XCspNI9i.png) - -那么在第一轮排序时,首先比较前两个元素: - -![image-20220904212608834](https://s2.loli.net/2022/09/04/VuiIDqPAr6SMd7H.png) - -我们发现前者更大,那么此时就需要交换,交换之后,继续向后比较后面的两个元素: - -![image-20220904212637156](https://s2.loli.net/2022/09/04/CxObP3Tkm4uzU98.png) - -我们发现后者更大,不变,继续看后两个: - -![image-20220904212720898](https://s2.loli.net/2022/09/04/jy5PtuvHO2BTQc8.png) - -此时前者更大,交换,继续比较后续元素: - -![image-20220904212855292](https://s2.loli.net/2022/09/04/64qSbtyMXQ37BDk.png) - -还是后者更大,继续交换,然后向后比较: - -![image-20220904212942212](https://s2.loli.net/2022/09/04/Rq1xwzupm8C3Q2Z.png) - -依然是后者更大,我们发现,只要是最大的元素,它会在每次比较中被一直往后丢: - -![image-20220904213034375](https://s2.loli.net/2022/09/04/vx16PpyFkKhVzsJ.png) - -最后,当前数组中最大的元素就被丢到最前面去了,这一轮排序结束,因为最大的已经排到对应的位置上了,所以说第二轮我们只需要考虑其之前的这些元素即可: - -![image-20220904213115671](https://s2.loli.net/2022/09/04/qWjTBSe4rlbUxIf.png) - -这样,我们就可以不断将最大的丢到最右边了,最后N轮排序之后,就是一个有序的数组了。 - -程序代码如下: - -```c -void bubbleSort(int arr[], int size){ - for (int i = 0; i < size; ++i) { - for (int j = 0; j < size - i - 1; ++j) { - //注意需要到N-1的位置就停止,因为要比较j和j+1 - //这里减去的i也就是已经排好的不需要考虑了 - if(arr[j] > arr[j + 1]) { //如果后面比前面的小,那么就交换 - int tmp = arr[j]; - arr[j] = arr[j + 1]; - arr[j + 1] = tmp; - } - } - } -} -``` - -只不过这种代码还是最原始的冒泡排序,我们可以对其进行优化: - -1. 实际上排序并不需要N轮,而是N-1轮即可,因为最后一轮只有一个元素未排序了,相当于已经排序了,所以说不需要再考虑了。 -2. 如果整轮排序中都没有出现任何的交换,那么说明数组已经是有序的了,不存在前一个比后一个大的情况。 - -所以,我们来改进一下: - -```c -void bubbleSort(int arr[], int size){ - for (int i = 0; i < size - 1; ++i) { //只需要size-1次即可 - _Bool flag = 1; //这里使用一个标记,默认为1表示数组是有序的 - for (int j = 0; j < size - i - 1; ++j) { - if(arr[j] > arr[j + 1]) { - flag = 0; //如果发生交换,说明不是有序的,把标记变成0 - int tmp = arr[j]; - arr[j] = arr[j + 1]; - arr[j + 1] = tmp; - } - } - if(flag) break; //如果没有发生任何交换,flag一定是1,数组已经有序,所以说直接结束战斗 - } -} -``` - -这样,我们才算编写完了一个优化版的冒泡排序。 - -当然,最后我们还需要介绍一个额外的概念:**排序的稳定性**,那么什么是稳定性呢?如果说大小相同的两个元素在排序之前和排序之后的先后顺序不变,这个排序算法就是稳定的。我们刚刚介绍的冒泡排序只会在前者大于后者的情况下才会进行交换,所以说不会影响到原本相等的两个元素顺序,因此冒泡排序是**稳定的**排序算法。 - -### 插入排序 - -我们来介绍一种新的排序算法,插入排序,准确地说应该叫直接插入排序,它的核心思想就像我们玩斗地主一样。 - -![image-20220904214541199](https://s2.loli.net/2022/09/04/RuSePqkc4ydVCHt.png) - -相信各位应该都玩过,每一轮游戏在开始之前,我们都要从牌堆去摸牌,那么摸到牌之后,在我们手中的牌顺序可能是乱的,这样肯定不行啊,牌都没理顺我们怎么知道哪些牌有多少呢?为了使得其有序,我们就会根据牌的顺序,将新摸过来的牌插入到对应的位置上,这样我们后面就不用再整理手里的牌了。 - -而插入排序实际上也是一样的原理,我们默认前面的牌都是已经排好序的(一开始就只有第一张牌是有序状态),剩余的部分我们会挨着遍历,然后将其插到前面对应的位置上去,动画演示地址:https://visualgo.net/zh/sorting - -设数组长度为N,详细过程为: - -* 共进行N轮排序。 -* 每轮排序会从后面依次选择一个元素,与前面已经处于有序的元素,从后往前进行比较,直到遇到一个不大于当前元素的的元素,将当前元素插入到此元素的前面。 -* 插入元素后,后续元素则全部后移一位。 -* 当后面的所有元素全部遍历完成,全部插入到对应的位置之后,排序完成。 - -比如下面的数组: - -![image-20220904212453328](https://s2.loli.net/2022/09/04/BYOvgd3XCspNI9i.png) - -此时我们默认第一个元素已经是处于有序状态,我们从第二个元素开始看: - -![image-20220904221510897](https://s2.loli.net/2022/09/04/Pd24brBwliqZuLh.png) - -将其取出,从后往前,与前面的有序序列依次进行比较,首先比较的是4,发现比4小,继续向前,发现已经到头了,所以说直接放到最前面即可,注意在放到最前面之前,先将后续元素后移,腾出空间: - -![image-20220904221648492](https://s2.loli.net/2022/09/04/JWbfZ9mVyrRMIvX.png) - -接着插入即可: - -![image-20220904221904359](https://s2.loli.net/2022/09/04/ZkhRcGqr8Ays93z.png) - -目前前面两个元素都是有序的状态了,我们继续来看第三个元素: - -![image-20220904221938583](https://s2.loli.net/2022/09/04/i2o4REcPXBaTWpg.png) - - 依然是从后往前看,我们发现上来就遇到了7小的4,所以说直接放到这个位置: - -![image-20220904222022949](https://s2.loli.net/2022/09/04/vZ4rXTmCsx5ikdJ.png) - -现在前面三个元素都是有序状态了,同样的,我们继续来看第四个元素: - -![image-20220904222105375](https://s2.loli.net/2022/09/04/YLheUfDB8HnuEk1.png) - -依次向前比较,发现到头了都没找到比1还小的元素,所以说将前面三个元素全部后移: - -![image-20220904222145903](https://s2.loli.net/2022/09/04/7pz8NDEZbmTXaJY.png) - -将1插入到对应的位置上去: - -![image-20220904222207544](https://s2.loli.net/2022/09/04/6bnyKs3Iq7S5cYC.png) - -现在前四个元素都是有序的状态了,我们只需要按照同样的方式完成后续元素的遍历,最后得到的就是有序的数组了,我们来尝试编写一下代码: - -```c -void insertSort(int arr[], int size){ - for (int i = 1; i < size; ++i) { //从第二个元素开始看 - int j = i, tmp = arr[i]; //j直接变成i,因为前面的都是有序的了,tmp相当于是抽出来的牌暂存一下 - while (j > 0 && arr[j - 1] > tmp) { //只要j>0并且前一个还大于当前待插入元素,就一直往前找 - arr[j] = arr[j - 1]; //找的过程中需要不断进行后移操作,把位置腾出来 - j--; - } - arr[j] = tmp; //j最后在哪个位置,就是是哪个位置插入 - } -} -``` - -当然,这个代码也是可以改进的,因为我们在寻找插入位置上逐个比较,花费了太多的时间,因为前面一部分元素已经是有序状态了,我们可以考虑使用二分搜索算法来查找对应的插入位置,这样就可以节省查找插入点的时间了: - -```c -int binarySearch(int arr[], int left, int right, int target){ - int mid; - while (left <= right) { - mid = (left + right) / 2; - if(target == arr[mid]) return mid + 1; //如果插入元素跟中间元素相等,直接返回后一位 - else if (target < arr[mid]) //如果大于待插入元素,说明插入位置肯定在左边 - right = mid - 1; //范围划到左边 - else - left = mid + 1; //范围划到右边 - } - return left; //不断划分范围,left也就是待插入位置了 -} - -void insertSort(int arr[], int size){ - for (int i = 1; i < size; ++i) { - int tmp = arr[i]; - int j = binarySearch(arr, 0, i - 1, tmp); //由二分搜索来确定插入位置 - for (int k = i; k > j; k--) arr[k] = arr[k - 1]; //依然是将后面的元素后移 - arr[j] = tmp; - } -} -``` - -我们最后还是来讨论一下,插入排序算法的稳定性。那么没有经过优化的插入排序,实际上是不断向前寻找到一个不大于待插入元素的元素,所以说遇到相等的元素时只会插入到其后面,并没有更改相同元素原本的顺序,所以说插入排序也是**稳定的**排序算法(不过后面使用了二分搜索优化之后就不稳定了,比如有序数组中连续两个相等的元素,现在又来了一个相等的元素,此时中间的正好找到的是排在最前面的相等元素,返回其后一个位置,新插入的元素会将原本排在第二个的相等元素挤到后面去了) - -### 选择排序 - -我们来看看最后一种选择排序(准确的说应该是直接选择排序),这种排序也比较好理解,我们每次都去后面找一个最小的放到前面即可,算法演示网站:https://visualgo.net/zh/sorting - -设数组长度为N,详细过程为: - -* 共进行N轮排序。 -* 每轮排序会从后面的所有元素中寻找一个最小的元素出来,然后与已经排序好的下一个位置进行交换。 -* 进行N轮交换之后,得到有序数组。 - -比如下面的数组: - -![image-20220904212453328](https://s2.loli.net/2022/09/04/BYOvgd3XCspNI9i.png) - -第一次排序需要从整个数组中寻找一个最小的元素,并将其与第一个元素进行交换: - -![image-20220905141347927](https://s2.loli.net/2022/09/05/JAoXIHwBDrg3y8n.png) - -交换之后,第一个元素已经是有序状态了,我们继续从剩下的元素中寻找一个最小的: - -![image-20220905141426011](https://s2.loli.net/2022/09/05/DsKk5GP6RJTOCXh.png) - -此时2正好在第二个位置,假装交换一下,这样前面两个元素都已经是有序的状态了,我们接着来看剩余的: - -![image-20220905141527050](https://s2.loli.net/2022/09/05/3B4n9cFKdxvWXtQ.png) - -此时发现3是最小的,所以说直接将其交换到第三个元素位置上: - -![image-20220905141629207](https://s2.loli.net/2022/09/05/iTt8UoAPRHIG9lJ.png) - -这样,前三个元素都是有序的了,通过不断这样交换,最后我们得到的数组就是一个有序的了,我们来尝试编写一下代码: - -```c -void selectSort(int arr[], int size){ - for (int i = 0; i < size - 1; ++i) { //因为最后一个元素一定是在对应位置上的,所以只需要进行N - 1轮排序 - int min = i; //记录一下当前最小的元素,默认是剩余元素中的第一个元素 - for (int j = i + 1; j < size; ++j) //挨个遍历剩余的元素,如果遇到比当前记录的最小元素还小的元素,就更新 - if(arr[min] > arr[j]) - min = j; - int tmp = arr[i]; //找出最小的元素之后,开始交换 - arr[i] = arr[min]; - arr[min] = tmp; - } -} -``` - -当然,对于选择排序,我们也可以进行优化,因为每次都需要选一个最小的出来,我们不妨再顺手选个最大的出来,小的往左边丢,大的往右边丢,这样就能够有双倍的效率完成了。 - -```c -void swap(int * a, int * b){ - int tmp = *a; - *a = *b; - *b = tmp; -} - -void selectSort(int arr[], int size){ - int left = 0, right = size - 1; //相当于左端和右端都是已经排好序的,中间是待排序的,所以说范围不断缩小 - while (left < right) { - int min = left, max = right; - for (int i = left; i <= right; i++) { - if (arr[i] < arr[min]) min = i; //同时找最小的和最大的 - if (arr[i] > arr[max]) max = i; - } - swap(&arr[max], &arr[right]); //这里先把大的换到右边 - //注意大的换到右边之后,有可能被换出来的这个就是最小的,所以说需要判断一下 - //如果遍历完发现最小的就是当前右边排序的第一个元素 - //此时因为已经被换出来了,所以说需要将min改到换出来的那个位置 - if (min == right) min = max; - swap(&arr[min], &arr[left]); //接着把小的换到左边 - left++; //这一轮完事之后,缩小范围 - right--; - } -} -``` - -最后我们来分析一下选择排序的稳定性,首先选择排序是每次选择最小的那一个,在向前插入时,会直接进行交换操作,比如原序列为 3,3,1,此时选择出1是最小的元素,与最前面的3进行交换,交换之后,原本排在第一个的3跑到最后去了,破坏了原有的顺序,所以说选择排序是**不稳定的**排序算法。 - -我们来总结一下上面所学的三种排序算法,假设需要排序的数组长度为`n`: - -* **冒泡排序(优化版):** - * **最好情况时间复杂度:**$O(n)$,如果本身就是有序的,那么我们只需要一次遍历,当标记检测到没有发生交换,直接就结束了,所以说一遍就搞定。 - * **最坏情况时间复杂度:**$O(n^2)$,也就是硬生生把每一轮都吃满了,比如完全倒序的数组就会这样。 - * **空间复杂度:**因为只需要一个变量来暂存一下需要交换的变量,所以说空间复杂度为 $O(1)$ - * **稳定性:**稳定 -* **插入排序:** - * **最好情况时间复杂度:**$O(n)$,如果本身就是有序的,因为插入的位置也是同样的位置,当数组本身就是有序的情况下时,每一轮我们不需要变动任何其他元素。 - * **最坏情况时间复杂度:**$O(n^2)$,比如完全倒序的数组就会这样,每一轮都得完完整整找到最前面插入。 - * **空间复杂度**:同样只需一个变量来存一下抽出来的元素,所以说空间复杂度为 $O(1)$ - * **稳定性:**稳定 -* **选择排序:** - * **最好情况时间复杂度:**$O(n^2)$,即使数组本身就是有序的,每一轮还是得将剩余部分挨个找完之后才能确定最小的元素,所以说依然需要平方阶。 - * **最坏情况时间复杂度:**$O(n^2)$,不用多说了吧。 - * **空间复杂度**:每一轮只需要记录最小的元素位置即可,所以说空间复杂度为 $O(1)$ - * **稳定性:**不稳定 - -表格如下,建议记住: - -| 排序算法 | 最好情况 | 最坏情况 | 空间复杂度 | 稳定性 | -| :------: | :------: | :------: | :--------: | :----: | -| 冒泡排序 | $O(n)$ | $O(n^2)$ | $O(1)$ | 稳定 | -| 插入排序 | $O(n)$ | $O(n^2)$ | $O(1)$ | 稳定 | -| 选择排序 | $O(n^2)$ | $O(n^2)$ | $O(1)$ | 不稳定 | - -*** - -## 进阶排序 - -前面我们介绍了三种基础排序算法,它们的平均情况时间复杂度都到达了 $O(n^2)$,那么能否找到更快的排序算法呢?这一部分,我们将继续介绍前面三种排序算法的进阶版本。 - -### 快速排序 - -在C语言程序设计篇,我们也介绍过快速排序,**快速排序**是冒泡排序的进阶版本,在冒泡排序中,进行元素的比较和交换是在相邻元素之间进行的,元素每次交换只能移动一个位置,所以比较次数和移动次数较多,效率相对较低。而在快速排序中,元素的比较和交换是从两端向中间进行的,较大的元素一轮就能够交换到后面的位置,而较小的元素一轮就能交换到前面的位置,元素每次移动的距离较远,所以比较次数和移动次数较少,就像它的名字一样,速度更快。 - -实际上快速排序每一轮的目的就是将大的丢到基准右边去,小的丢到基准左边去。 - -设数组长度为N,详细过程为: - -* 在一开始,排序范围是整个数组 -* 排序之前,我们选择整个排序范围内的第一个元素作为基准,对排序范围内的元素进行快速排序 -* 先从最右边向左看,依次将每一个元素与基准元素进行比较,如果发现比基准元素小,那么就与左边遍历位置上的元素(一开始是基准元素的位置)进行交换,此时保留右边当前遍历的位置。 -* 交换后,转为从左往右开始遍历元素,如果发现比基准元素大,那么就与之前保留的右边遍历的位置上的元素进行交换,同样保留左边当前的位置,循环执行上一个步骤。 -* 当左右遍历撞到一起时,本轮快速排序完成,最后在最中间的位置就是基准元素的位置了。 -* 以基准位置为中心,划分左右两边,以同样的方式执行快速排序。 - -比如下面的数组: - -![image-20220904212453328](https://s2.loli.net/2022/09/04/BYOvgd3XCspNI9i.png) - -首先我们选择第一个元素4作为基准元素,一开始左右指针位于两端: - -![image-20220905210056432](https://s2.loli.net/2022/09/05/LneNjifuZ4JOgKl.png) - -此时从右往左开始看,直到遇到一个比4小的元素,首先是6,肯定不是,将指针往后移动: - -![image-20220905210625181](https://s2.loli.net/2022/09/05/zVkd4wvAq9FK2ET.png) - -此时继续让3和4进行比较,发现比4小,那么此时直接将3交换(其实直接覆盖过去就行了)到左边指针所指向的元素位置: - -![image-20220905210730105](https://s2.loli.net/2022/09/05/Z7y3sO5qDWxYdiL.png) - -此时我们转为从左往右看,如果遇到比4大的元素,就交换到右边指针处,3肯定不是了,因为刚刚才缓过来,接着就是2: - -![image-20220905210851474](https://s2.loli.net/2022/09/05/cXk7xEp2IfSH9iq.png) - -2也没有4大,所以说继续往后看,此时7比4要大,那么继续交换: - -![image-20220905211300102](https://s2.loli.net/2022/09/05/zn8asNlCbOyv1cA.png) - -接着,又开始从右往左看: - -![image-20220905211344027](https://s2.loli.net/2022/09/05/APvaMt4jJV79Dpn.png) - -此时5是比4要大的,继续向前,发现1比4要小,所以说继续交换: - -![image-20220905211427939](https://s2.loli.net/2022/09/05/cCi1xkSfIVjEsPK.png) - -接着又转为从左往右看,此时两个指针撞到一起了,排序结束,最后两个指针所指向的位置就是给基准元素的位置了: - -![image-20220905211543845](https://s2.loli.net/2022/09/05/eaRN5ZvVs8bc9lU.png) - -本轮快速排序结束后,左边不一定都是有序的,但是一定比基准元素要小,右边一定比基准元素大。接着我们以基准为中心,分成两个部分再次进行快速排序: - -![image-20220905211741787](https://s2.loli.net/2022/09/05/4MLoPCKs1W9Y3Ox.png) - -这样,我们最后就可以使得整个数组有序了,当然快速排序还有其他的说法,有些是左右都找到了再交换,我们这里的是只要找到就丢过去。既然现在思路已经清楚了,我们就来尝试实现一下快速排序吧: - -```c -void quickSort(int arr[], int start, int end){ - if(start >= end) return; //范围不可能无限制的划分下去,要是范围划得都没了,肯定要结束了 - int left = start, right = end, pivot = arr[left]; //这里我们定义两个指向左右两个端点的指针,以及取出基准 - while (left < right) { //只要两个指针没相遇,就一直循环进行下面的操作 - while (left < right && arr[right] >= pivot) right--; //从右向左看,直到遇到比基准小的 - arr[left] = arr[right]; //遇到比基准小的,就丢到左边去 - while (left < right && arr[left] <= pivot) left++; //从左往右看,直到遇到比基准大的 - arr[right] = arr[left]; //遇到比基准大的,就丢到右边去 - } - arr[left] = pivot; //最后相遇的位置就是基准存放的位置了 - quickSort(arr, start, left - 1); //不包含基准,划分左右两边,再次进行快速排序 - quickSort(arr, left + 1, end); -} -``` - -这样,我们就实现了快速排序。我们还是来分析一下快速排序的稳定性,快速排序是只要遇到比基准小或者大的元素就直接交换,比如原数组就是:**2**,2,1,此时第一个元素作为基准,首先右边1会被丢过来,变成:1,2,1,然后从左往右,因为只有遇到比基准2更大的元素才会换,所以说最后基准会被放到最后一个位置:1,2,**2**,此时原本应该在前面的2就跑到后面去了,所以说快速排序算法,是一种**不稳定的**排序算法。 - -**双轴快速排序(选学)** - -这里需要额外补充个快速排序的升级版,**双轴快速排序**,Java语言中的数组工具类则是采用的此排序方式对大数组进行排序的。我们来看看它相比快速排序,又做了哪些改进。首先普通的快速排序算法在遇到极端情况时可能会这样: - -![image-20220906131959909](https://s2.loli.net/2022/09/06/aEbUInwHOTi1GF7.png) - -整个数组正好是倒序的,那么相当于上来就要把整个数组找完,然后把8放到最后一个位置,此时第一轮结束: - -![image-20220906132112592](https://s2.loli.net/2022/09/06/erMHzcW58vVqFGa.png) - -由于8直接跑到最右边了,那么此时没有右半部分,只有做半部分,此时左半部分继续进行快速排序: - -![image-20220906132244369](https://s2.loli.net/2022/09/06/6yQa7e8VTYgpUZN.png) - -此时1又是最小的一个元素,导致最后遍历完了,1都还是在那个位置,此时没有左半部分,只有右半部分: - -![image-20220906132344525](https://s2.loli.net/2022/09/06/r9LlRfEotZMmdDn.png) - -此时基准是7,又是最大的,真是太倒霉了,排完之后7跑到最左边,还是没有右半部分: - -![image-20220906132437765](https://s2.loli.net/2022/09/06/PicWXjIBMnfUd7H.png) - -我们发现,在这种极端情况下,每一轮需要完整遍历整个范围,并且每一轮都会有一个最大或是最小的元素被推向两边,这不就是冒泡排序吗?所以说,在极端情况下,快速排序会退化为冒泡排序,因此有些快速排序会随机选取基准元素。为了解决这种在极端情况下出现的问题,我们可以再添加一个基准元素,这样即使出现极端情况,除非两边都是最小元素或是最大元素,否则至少一个基准能正常进行分段,出现极端情况的概率也会减小很多: - -![image-20220906132945691](https://s2.loli.net/2022/09/06/2YBc1goqMGwuTs4.png) - -此时第一个元素和最后一个元素都作为基准元素,将整个返回划分为三段,假设基准1小于基准2,那么第一段存放的元素全部要小于基准1,第二段存放的元素全部要不小于基准1同时不大于基准2,第三段存放的元素全部要大于基准2: - -![image-20220906133219853](https://s2.loli.net/2022/09/06/MsvJC1OtnbuGye9.png) - -因此,在划分为三段之后,每轮双轴快排结束后需要对这三段分别继续进行双轴快速排序,最后就可以使得整个数组有序了,当然这种排序算法更适用于哪些量比较大的数组,如果量比较小的话,考虑到双轴快排要执行这么多操作,其实还不如插入排序来的快。 - -我们来模拟一下双轴快速排序是如何进行的: - -![image-20220906140255444](https://s2.loli.net/2022/09/06/WFASGVCJaQHhBX3.png) - -首先取出首元素和尾元素作为两个基准,然后我们需要对其进行比较,如果基准1大于基准2,那么需要先交换两个基准,只不过这里因为4小于6,所以说不需要进行交换。 - -此时我们需要创建三个指针: - -![image-20220906140538076](https://s2.loli.net/2022/09/06/y283Ne7M6XmUtZA.png) - -因为有三个区域,其中蓝色指针位置及其左边的区域都是小于基准1的,橙色指针左边到蓝色指针之间的区域都是不小于基准1且不大于基准2的,绿色指针位置及其右边的区域都是大于基准2的,橙色指针和绿色指针之间的区域,都是待排序区域。 - -首先我们从橙色指针所指元素开始进行判断,分三种情况: - -* 如果小于基准1,那么需要先将蓝色指针向后移,把元素交换换到蓝色指针那边去,然后橙色指针也向后移动。 -* 如果不小于基准1且不大于基准2,那么不需要做什么,直接把橙色指针向前移动即可,因为本身就是这个范围。 -* 如果大于基准2,那么需要丢到右边去,先将右边指针左移,不断向前找到一个不比基准2大的,这样才能顺利地交换过去。 - -首先我们来看看,此时橙色指针指向的是2,那么2是小于基准1的,我们需要先将蓝色指针后移,然后交换橙色和蓝色指针上的元素,只不过这里由于是同一个,所以说不变,此时两个指针都后移了一位: - -![image-20220906141556398](https://s2.loli.net/2022/09/06/HA8tKzv6Uri45p1.png) - -同样的,我们继续来看橙色指针所指元素,此时为7,大于基准2,那么此时需要在右边找到一个不大于基准2的元素: - -![image-20220906141653453](https://s2.loli.net/2022/09/06/Nj2PvlyYCnSb3kV.png) - -绿色指针从右开始向左找,此时找到3,直接交换橙色指针和蓝色指针元素: - -![image-20220906141758610](https://s2.loli.net/2022/09/06/J3ymMprRPTSHFWi.png) - -下一轮开始继续看橙色指针元素,此时发现是小于基准1的,所以说先向前移动蓝色指针,发现和橙色又在一起了,交换了跟没交换一样,此时两个指针都后移了一位: - -![image-20220906141926006](https://s2.loli.net/2022/09/06/7iSnb8ctryz6A1l.png) - -新的一轮继续来看橙色指针所指元素,此时我们发现1也是小于基准1的,先移动蓝色指针,再交换,在移动橙色指针,跟上面一样,交换个寂寞: - -![image-20220906142041202](https://s2.loli.net/2022/09/06/wrgQAsSTHjd5Ynq.png) - -此时橙色指针指向8,大于基准2,那么同样需要在右边继续找一个不大于基准2的进行交换: - -![image-20220906142134949](https://s2.loli.net/2022/09/06/udGnpJHzxP8SK4l.png) - -此时找到5,满足条件,交换即可: - -![image-20220906142205055](https://s2.loli.net/2022/09/06/oEycNqQVhbuC3Tx.png) - -我们继续来看橙色指针,发现此时橙色指针元素不小于基准1且不大于基准2,那么根据前面的规则,只需要向前移动橙色指针即可: - -![image-20220906142303329](https://s2.loli.net/2022/09/06/YSxmL2tiyBQGl8C.png) - -此时橙色指针和绿色指针撞一起了,没有剩余待排序元素了,最后我们将两个位于两端点基准元素与对应的指针进行交换,基准1与蓝色指针交换,基准2与绿色指针进行交换: - -![image-20220906142445417](https://s2.loli.net/2022/09/06/C2AnBDiUp7qxuSb.png) - -此时分出来的三个区域,正好满足条件,当然这里运气好,直接整个数组就有序了,不过按照正常的路线,我们还得继续对这剩下的三个区域进行双轴快速排序,最后即可排序完成。 - -现在我们来尝试编写一下双轴快速排序的代码: - -```c -void dualPivotQuickSort(int arr[], int start, int end) { - if(start >= end) return; //首先结束条件还是跟之前快速排序一样,因为不可能无限制地分下去,分到只剩一个或零个元素时该停止了 - if(arr[start] > arr[end]) //先把首尾两个基准进行比较,看看谁更大 - swap(&arr[start], &arr[end]); //把大的换到后面去 - int pivot1 = arr[start], pivot2 = arr[end]; //取出两个基准元素 - int left = start, right = end, mid = left + 1; //因为分了三块区域,此时需要三个指针来存放 - while (mid < right) { //因为左边冲在最前面的是mid指针,所以说跟之前一样,只要小于right说明mid到right之间还有没排序的元素 - if(arr[mid] < pivot1) //如果mid所指向的元素小于基准1,说明需要放到最左边 - swap(&arr[++left], &arr[mid++]); //直接跟最左边交换,然后left和mid都向前移动 - else if (arr[mid] <= pivot2) { //在如果不小于基准1但是小于基准2,说明在中间 - mid++; //因为mid本身就是在中间的,所以说只需要向前缩小范围就行 - } else { //最后就是在右边的情况了 - while (arr[--right] > pivot2 && right > mid); //此时我们需要找一个右边的位置来存放需要换过来的元素,注意先移动右边指针 - if(mid >= right) break; //要是把剩余元素找完了都还没找到一个比基准2小的,那么就直接结束,本轮排序已经完成了 - swap(&arr[mid], &arr[right]); //如果还有剩余元素,说明找到了,直接交换right指针和mid指针所指元素 - } - } - swap(&arr[start], &arr[left]); //最后基准1跟left交换位置,正好左边的全部比基准1小 - swap(&arr[end], &arr[right]); //最后基准2跟right交换位置,正好右边的全部比基准2大 - dualPivotQuickSort(arr, start, left - 1); //继续对三个区域再次进行双轴快速排序 - dualPivotQuickSort(arr, left + 1, right - 1); - dualPivotQuickSort(arr, right + 1, end); -} -``` - -此部分仅作为选学,不强制要求。 - -### 希尔排序 - -希尔排序是直接插入排序的进阶版本(希尔排序又叫**缩小增量排序**)插入排序虽然很好理解,但是在极端情况下会出现让所有已排序元素后移的情况(比如刚好要插入的是一个特别小的元素)为了解决这种问题,希尔排序对插入排序进行改进,它会对整个数组按照步长进行分组,优先比较距离较远的元素。 - -这个步长是由一个增量序列来定的,这个增量序列很关键,大量研究表明,当增量序列为 `dlta[k] = 2^(t-k+1)-1(0<=k<=t<=(log2(n+1)))`时,效率很好,只不过为了简单,我们一般使用 $\frac {n} {2}$、$\frac {n} {4}$、$\frac {n} {8}$、...、1 这样的增量序列。 - -设数组长度为N,详细过程为: - -* 首先求出最初的步长,n/2即可。 -* 我们将整个数组按照步长进行分组,也就是两两一组(如果n为奇数的话,第一组会有三个元素) -* 我们分别在这些分组内进行插入排序。 -* 排序完成后,我们将步长/2,重新分组,重复上述步骤,直到步长为1时,插入排序最后一遍结束。 - -这样的话,因为组内就已经调整好了一次顺序,小的元素尽可能排在前面,即使在最后一遍排序中出现遇到小元素要插入的情况,也不会有太多的元素需要后移。 - -我们以下面的数组为例: - -![image-20220905223505975](https://s2.loli.net/2022/09/05/GLEjym78BWNUtCf.png) - -首先数组长度为8,直接整除2,得到34,那么步长就是4了,我们按照4的步长进行分组: - -![image-20220905223609936](https://s2.loli.net/2022/09/05/S72NcgKBzIA54om.png) - -其中,4、8为第一组,2、5 为第二组,7、3为第三组,1、6为第四组,我们分别在这四组内进行插入排序,组内排序之后的结果为: - -![image-20220905223659584](https://s2.loli.net/2022/09/05/XO4a9HKP7vhGTmf.png) - -可以看到目前小的元素尽可能地在往前面走,虽然还不是有序的,接着我们缩小步长,4/2=2,此时按照这个步长划分: - -![image-20220905223804907](https://s2.loli.net/2022/09/05/sdKhcGmapgOZkCP.png) - -此时4、3、8、7为一组,2、1、5、6为一组,我们继续在这两个组内进行排序,得到: - -![image-20220905224111803](https://s2.loli.net/2022/09/05/MD8UFxJvg274Qw6.png) - -最后我们继续将步长/2,得到2/2=1,此时步长变为1,也就相当于整个数组为一组,再次进行一次插入排序,此时我们会发现,小的元素都靠到左边来了,此时再进行插入排序会非常轻松。 - -我们现在就来尝试编写一下代码: - -```c -void shellSort(int arr[], int size){ - int delta = size / 2; - while (delta >= 1) { - //这里依然是使用之前的插入排序,不过此时需要考虑分组了 - for (int i = delta; i < size; ++i) { //我们需要从delta开始,因为前delta个组的第一个元素默认是有序状态 - int j = i, tmp = arr[i]; //这里依然是把待插入的先抽出来 - while (j >= delta && arr[j - delta] > tmp) { - //注意这里比较需要按步长往回走,所以说是j - delta,此时j必须大于等于delta才可以,如果j - delta小于0说明前面没有元素了 - arr[j] = arr[j - delta]; - j -= delta; - } - arr[j] = tmp; - } - delta /= 2; //分组插排完事之后,重新计算步长 - } -} -``` - -虽然这里用到了三层循环嵌套,但是实际上的时间复杂度可能比 $O(n^2)$ 还小,因为能够保证小的元素一定往左边靠,所以排序次数实际上并没有我们想象中的那么多,由于证明过程过于复杂,这里就不列出了。 - -那么希尔排序是不是稳定的呢?因为现在是按步长进行分组,有可能会导致原本相邻的两个相同元素,后者在自己的组内被换到前面去了,所以说希尔排序是**不稳定的**排序算法。 - -### 堆排序 - -我们来看最后一种,堆排序也是选择排序的一种,但是它能够比直接选择排序更快。还记得我们前面讲解的大顶堆和小顶堆吗?我们来回顾一下: - -> 对于一棵完全二叉树,树中父亲结点都比孩子结点小的我们称为**小根堆**(小顶堆),树中父亲结点都比孩子结点大则是**大根堆** - -得益于堆是一棵完全二叉树,我们可以很轻松地使用数组来进行表示: - -![image-20220818110224673](https://s2.loli.net/2022/08/18/XpYVN2gslOfWLSr.png) - -我们通过构建一个堆,就可以将一个无序的数组依次输入,最后存放的序列是一个按顺序排放的序列,利用这种性质,我们可以很轻松地利用堆进行排序,我们先来写一个小顶堆: - -```c -typedef int E; -typedef struct MinHeap { - E * arr; - int size; - int capacity; -} * Heap; - -_Bool initHeap(Heap heap){ - heap->size = 0; - heap->capacity = 10; - heap->arr = malloc(sizeof (E) * heap->capacity); - return heap->arr != NULL; -} - -_Bool insert(Heap heap, E element){ - if(heap->size == heap->capacity) return 0; - int index = ++heap->size; - while (index > 1 && element < heap->arr[index / 2]) { - heap->arr[index] = heap->arr[index / 2]; - index /= 2; - } - heap->arr[index] = element; - return 1; -} - -E delete(Heap heap){ - E max = heap->arr[1], e = heap->arr[heap->size--]; - int index = 1; - while (index * 2 <= heap->size) { - int child = index * 2; - if(child < heap->size && heap->arr[child] > heap->arr[child + 1]) - child += 1; - if(e <= heap->arr[child]) break; - else heap->arr[index] = heap->arr[child]; - index = child; - } - heap->arr[index] = e; - return max; -} -``` - -接着我们只需要将这些元素挨个插入到堆中,然后再挨个拿出来,得到的就是一个有序的顺序了: - -```c -int main(){ - int arr[] = {3, 5, 7, 2, 9, 0, 6, 1, 8, 4}; - - struct MinHeap heap; //先创建堆 - initHeap(&heap); - for (int i = 0; i < 10; ++i) - insert(&heap, arr[i]); //直接把乱序的数组元素挨个插入 - for (int i = 0; i < 10; ++i) - arr[i] = delete(&heap); //然后再一个一个拿出来,就是按顺序的了 - - for (int i = 0; i < 10; ++i) - printf("%d ", arr[i]); -} -``` - -最后得到的结果为: - -![image-20220906001134488](https://s2.loli.net/2022/09/06/iedURC8vVpcMgn6.png) - -虽然这样用起来比较简单,但是需要额外 $O(n)$ 的空间来作为堆,所以我们可以对其进行进一步的优化,减少其空间上的占用。那么怎么进行优化呢,我们不妨换个思路,直接对给定的数组进行堆的构建。 - -设数组长度为N,详细过程为: - -* 首先将给定的数组调整为一个大顶堆 -* 进行N轮选择,每次都选择大顶堆顶端的元素从数组末尾开始向前存放(交换堆顶和堆的最后一个元素) -* 交换完成后,重新对堆的根结点进行调整,使其继续满足大顶堆的性质,然后重复上述操作。 -* 当N轮结束后,得到的就是从小到大排列的数组了。 - -我们先将给定数组变成一棵完全二叉树,以下面数组为例: - -![image-20220906220020172](https://s2.loli.net/2022/09/06/GTkWlzpZbxMX58K.png) - -此时,这棵二叉树还并不是堆,我们的首要目标是将其变成一个大顶堆。那么怎么将这棵二叉树变成一个大顶堆呢?我们只需要从最后一个非叶子结点(从上往下的顺序)开始进行调整即可,比如此时1是最后一个非叶子结点,所以说就从1开始,我们需要进行比较,如果其孩子结点大于它,那么需要将最大的那个孩子交换上来,此时其孩子结点6大于1,所以说需要交换: - -![image-20220906221306519](https://s2.loli.net/2022/09/06/bpnSDlOLKawGRxT.png) - -接着我们来看倒数第二个非叶子结点,也就是7,那么此时两个孩子都是小于它的,所以说不需要做任何调整,我们接着来看倒数第三个非叶子结点2,此时2的两个孩子6、8都大于2,那么我们选择两个孩子里面一个最大的交换上去: - -![image-20220906221504364](https://s2.loli.net/2022/09/06/svNPURhJbzAQo9d.png) - -最后就剩下根结点这一个非叶子结点了,此时我们4的左右孩子都大于4,那么依然需要进行调整: - -![image-20220906221657599](/Users/nagocoler/Library/Application Support/typora-user-images/image-20220906221657599.png) - -在调整之后,还没有结束,因为此时4换下去之后依然不满足大顶堆的性质,此时4的左孩子大于4,我们还需要继续向下看: - -![image-20220906221833012](https://s2.loli.net/2022/09/06/MmzbFTLYSZAW4gv.png) - -交换之后,此时整个二叉树就满足大顶堆的性质了,我们第一次初始调整也就完成了。 - -此时开始第二步,我们需要一个一个地将堆顶元素往后面进行交换,相当于每次都去取一个最大的出来,直到取完,首先交换堆顶元素和最后一个元素: - -![image-20220906222327297](https://s2.loli.net/2022/09/06/8fBeubxnqD1rJEZ.png) - -此时整个数组中最大的元素已经排到对应的位置上了,然后我们不再考虑最后一个元素,此时将前面的剩余元素继续看做一棵完全二叉树,对根结点重新进行一次堆化(只需要调整根结点即可,因为其他非叶子结点的没有变动),使得其继续满足大顶堆的性质: - -![image-20220906222819554](https://s2.loli.net/2022/09/06/2RTG76ejsKyCthU.png) - -还没完,继续调整: - -![image-20220906222858752](https://s2.loli.net/2022/09/06/clEdF5YBO7iKDP6.png) - -此时第一轮结束,接着第二轮,重复上述操作,首先依然是将堆顶元素丢到倒数第二个位置上,相当于将倒数第二大的元素放到对应的位置上去: - -![image-20220906222934602](https://s2.loli.net/2022/09/06/KaZGOsWD2chNf3i.png) - -此时已经有两个元素排好序了,同样的,我们继续将剩余元素看做一个完全二叉树,继续对根结点进行堆化操作,使得其继续满足大顶堆性质: - -![image-20220906223110734](https://s2.loli.net/2022/09/06/PD7FBrlqc1R8LWE.png) - -第三轮同样的思路,将最大的交换到后面去: - -![image-20220906223326135](https://s2.loli.net/2022/09/06/tKhYbOqv6Ezmc8R.png) - -通过N轮排序,最后每一个元素都可以排到对应的位置上了,根据上面的思路,我们来尝试编写一下代码: - -```c -//这个函数就是对start顶点位置的子树进行堆化 -void makeHeap(int* arr, int start, int end) { - while (start * 2 + 1 <= end) { //如果有子树,就一直往下,因为调整之后有可能子树又不满足性质了 - int child = start * 2 + 1; //因为下标是从0开始,所以左孩子下标就是i * 2 + 1,右孩子下标就是i * 2 + 2 - if(child + 1 <= end && arr[child] < arr[child + 1]) //如果存在右孩子且右孩子比左孩子大 - child++; //那就直接看右孩子 - if(arr[child] > arr[start]) //如果上面选出来的孩子,比父结点大,那么就需要交换,大的换上去,小的换下来 - swap(&arr[child], &arr[start]); - start = child; //继续按照同样的方式前往孩子结点进行调整 - } -} - -void heapSort(int arr[], int size) { - for(int i= size/2 - 1; i >= 0; i--) //我们首选需要对所有非叶子结点进行一次堆化操作,需要从最后一个到第一个,这里size/2计算的位置刚好是最后一个非叶子结点 - makeHeap(arr, i, size - 1); - for (int i = size - 1; i > 0; i--) { //接着我们需要一个一个把堆顶元素搬到后面,有序排列 - swap(&arr[i], &arr[0]); //搬运实际上就是直接跟倒数第i个元素交换,这样,每次都能从堆顶取一个最大的过来 - makeHeap(arr, 0, i - 1); //每次搬运完成后,因为堆底元素被换到堆顶了,所以需要再次对根结点重新进行堆化 - } -} -``` - -最后我们来分析一下堆排序的稳定性,实际上堆排序本身也是在进行选择,每次都会选择堆顶元素放到后面,只不过堆是一直在动态维护的。实际上从堆顶取出元素时,都会与下面的叶子进行交换,有可能会出现: - -![image-20220906223706019](https://s2.loli.net/2022/09/06/oVT4rvRsKnHyqQl.png) - -所以说堆排序是**不稳定的**排序算法。 - -最后我们还是来总结一下上面的三种排序算法的相关性质: - -| 排序算法 | 最好情况 | 最坏情况 | 空间复杂度 | 稳定性 | -| :------: | :----------: | :--------: | :--------: | :----: | -| 快速排序 | $O(nlogn)$ | $O(n^2)$ | $O(logn)$ | 不稳定 | -| 希尔排序 | $O(n^{1.3})$ | $O(n^2)$ | $O(1)$ | 不稳定 | -| 堆排序 | $O(nlogn)$ | $O(nlogn)$ | $O(1)$ | 不稳定 | - -*** - -## 其他排序方案 - -除了我们前面介绍的几种排序算法之外,还有一些其他类型的排序算法,我们都来认识一下吧。 - -### 归并排序 - -归并排序利用递归分治的思想,将原本的数组进行划分,然后首先对划分出来的小数组进行排序,然后最后在合并为一个有序的大数组,还是很好理解的: - -![image-20220906232451040](https://s2.loli.net/2022/09/06/PuBRFqHKDvxIpwZ.png) - -我们以下面的数组为例: - -![image-20220905223505975](https://s2.loli.net/2022/09/05/GLEjym78BWNUtCf.png) - -在一开始我们先不急着进行排序,我们先一半一半地进行划分: - -![image-20220907135544173](https://s2.loli.net/2022/09/07/oi74M9zRdktHNjC.png) - -继续进行划分: - -![image-20220907135744253](https://s2.loli.net/2022/09/07/yFe9d7XDrqH8MWJ.png) - -最后会变成这样的一个一个的元素: - -![image-20220907135927289](https://s2.loli.net/2022/09/07/ZdV5RWpAjQzeHn1.png) - -此时我们就可以开始归并排序了,注意这里的合并并不是简简单单地合并,我们需要按照从小到大的顺序,依次对每个元素进行合并,第一组树4和2,此时我们需要从这两个数组中先选择小的排到前面去: - -![image-20220907140219455](https://s2.loli.net/2022/09/07/Is1lUMzTaSdk7bB.png) - -排序完成后,我们继续向上合并: - -![image-20220907141217008](https://s2.loli.net/2022/09/07/eAsX37G2BKdNHZj.png) - -最后我们再将这两个数组合并到原有的规模: - -![image-20220907141442229](https://s2.loli.net/2022/09/07/rq7lDhdvTQympUS.png) - -最后就能得到一个有序的数组了。 - -实际上这种排序算法效率也很高,只不过需要牺牲一个原数组大小的空间来对这些分解后的数据进行排序,代码如下: - -```c -void merge(int arr[], int tmp[], int left, int leftEnd, int right, int rightEnd){ - int i = left, size = rightEnd - left + 1; //这里需要保存一下当前范围长度,后面使用 - while (left <= leftEnd && right <= rightEnd) { //如果两边都还有,那么就看哪边小,下一个就存哪一边的 - if(arr[left] <= arr[right]) //如果左边的小,那么就将左边的存到下一个位置(这里i是从left开始的) - tmp[i++] = arr[left++]; //操作完后记得对i和left都进行自增 - else - tmp[i++] = arr[right++]; - } - while (left <= leftEnd) //如果右边看完了,只剩左边,直接把左边的存进去 - tmp[i++] = arr[left++]; - while (right <= rightEnd) //同上 - tmp[i++] = arr[right++]; - for (int j = 0; j < size; ++j, rightEnd--) //全部存到暂存空间中之后,暂存空间中的内容都是有序的了,此时挨个搬回原数组中(注意只能搬运范围内的) - arr[rightEnd] = tmp[rightEnd]; -} - -void mergeSort(int arr[], int tmp[], int start, int end){ //要进行归并排序需要提供数组和原数组大小的辅助空间 - if(start >= end) return; //依然是使用递归,所以说如果范围太小,就不用看了 - int mid = (start + end) / 2; //先找到中心位置,一会分两半 - mergeSort(arr, tmp, start, mid); //对左半和右半分别进行归并排序 - mergeSort(arr, tmp, mid + 1, end); - merge(arr, tmp, start, mid, mid + 1, end); - //上面完事之后,左边和右边都是有序状态了,此时再对整个范围进行一次归并排序即可 -} -``` - -因为归并排序最后也是按照小的优先进行合并,如果遇到相等的,也是优先将前面的丢回原数组,所以说排在前面的还是排在前面,因此归并排序也是**稳定的**排序算法。 - -### 桶排序和基数排序 - -在开始讲解桶排序之前,我们先来看看计数排序,它要求是数组长度为N,且数组内的元素取值范围是0 - M-1 之间(M小于等于N) - -算法演示网站:https://visualgo.net/zh/sorting?slide=1 - -比如下面的数组,所有的元素范围是 1-6之间: - -![image-20220907142933725](https://s2.loli.net/2022/09/07/5H29S1YpwUXh63L.png) - -我们先对其进行一次遍历,统计每个元素的出现次数,统计完成之后,我们就能够明确在排序之后哪个位置可以存放值为多少的元素了: - -![image-20220907145336855](https://s2.loli.net/2022/09/07/dBvf8LYJnl47HmN.png) - -我们来分析一下,首先1只有一个,那么只会占用一个位置,2也只有一个,所以说也只会占用一个位置,以此类推: - -![image-20220907145437992](https://s2.loli.net/2022/09/07/FXOMDrohvgNECLj.png) - -所以说我们直接根据统计的结果,把这些值挨个填进去就行了,而且还是稳定的,按顺序,有几个填几个就可以了: - -![image-20220907145649061](https://s2.loli.net/2022/09/07/R4rPLtDVxYZcvEI.png) - -是不是感觉很简单,而且只需要遍历一次进行统计就行了。 - -当然肯定是有缺点的: - -1. 当数组中最大最小值差距过大时,我们得申请更多的空间来进行计数,所以不适用于计数排序。 -2. 当数组中元素值不是离散的(也就是不是整数的情况下)就没办法统计了。 - -我们接着来看桶排序,它是计数排序的延伸,思路也比较简单,它同样要求是数组长度为N,且数组内的元素取值范围是0 - M-1 之间(M小于等于N),比如现在有1000个学生,现在需要对这些学生按照成绩进行排序,因为成绩的范围是0-100,所以说我们可以建立101个桶来分类存放。 - -比如下面的数组: - -![image-20220907142933725](https://s2.loli.net/2022/09/07/5H29S1YpwUXh63L.png) - -此数组中包含1-6的元素,所以说我们可以建立 6个桶来进行统计: - -![image-20220907143715938](https://s2.loli.net/2022/09/07/ImGw7Z6xhBnLAWr.png) - -这样,我们只需要遍历一次,就可以将所有的元素分类丢到这些桶中,最后我们只需要依次遍历这些桶,然后把里面的元素拿出来依次存放回去得到的就是有序的数组了: - -![image-20220907144255326](https://s2.loli.net/2022/09/07/d4ZMjNS8UmaCRef.png) - -只不过桶排序虽然也很快,但是同样具有与上面计数排序一样的限制,我们可以将每个桶接纳一定范围内的元素,来减小桶的数量,但是这样会导致额外的时间开销。 - -我们最后来看看基数排序,基数排序依然是一种依靠统计来进行的排序算法,但是它不会因为范围太大而导致无限制地申请辅助空间。它的思路是,分出10个基数出来(从0 - 9)我们依然是只需要遍历一次,我们根据每一个元素的个位上的数字,进行分类,因为现在有10个基数,也就是10个桶。个位完事之后再看十位、百位... - -算法演示网站:https://visualgo.net/zh/sorting - -![image-20220907152403435](https://s2.loli.net/2022/09/07/c2qtbQJOW7mS6Hr.png) - -先按照个位数进行统计,然后排序,再按照十位进行统计,然后排序,最后得到的结果就是最终的结果了: - -![image-20220907152903020](https://s2.loli.net/2022/09/07/WJjtbBMKIVYUlNs.png) - -然后是十位数: - -![image-20220907153005797](https://s2.loli.net/2022/09/07/mI4yjLhXSP17cFq.png) - -最后再次按顺序取出来: -![image-20220907153139536](https://s2.loli.net/2022/09/07/2njAIfvDQbLxNyt.png) - -成功得到有序数组。 - -最后我们来总结一下所有排序算法的相关性质: - -| 排序算法 | 最好情况 | 最坏情况 | 空间复杂度 | 稳定性 | -| :------: | :-------------: | :-------------: | :--------: | :----: | -| 冒泡排序 | $O(n)$ | $O(n^2)$ | $O(1)$ | 稳定 | -| 插入排序 | $O(n)$ | $O(n^2)$ | $O(1)$ | 稳定 | -| 选择排序 | $O(n^2)$ | $O(n^2)$ | $O(1)$ | 不稳定 | -| 快速排序 | $O(nlogn)$ | $O(n^2)$ | $O(logn)$ | 不稳定 | -| 希尔排序 | $O(n^{1.3})$ | $O(n^2)$ | $O(1)$ | 不稳定 | -| 堆排序 | $O(nlogn)$ | $O(nlogn)$ | $O(1)$ | 不稳定 | -| 归并排序 | $O(nlogn)$ | $O(nlogn)$ | $O(n)$ | 稳定 | -| 计数排序 | $O(n + k)$ | $O(n + k)$ | $O(k)$ | 稳定 | -| 桶排序 | $O(n + k)$ | $O(n^2)$ | $O(k + n)$ | 稳定 | -| 基数排序 | $O(n \times k)$ | $O(n \times k)$ | $O(k+n)$ | 稳定 | - -### 猴子排序 - -猴子排序比较佛系,因为什么时候能排完,全看运气! - -> 无限猴子定理最早是由埃米尔·博雷尔在1909年出版的一本谈概率的书籍中提到的,此书中介绍了“打字的猴子”的概念。无限猴子定理是概率论中的柯尔莫哥洛夫的零一律的其中一个命题的例子。大概意思是,如果让一只猴子在打字机上随机地进行按键,如果一直不停的这样按下去,只要时间达到无穷时,这只猴子就几乎必然可以打出任何给定的文字,甚至是莎士比亚的全套著作也可以打出来。 - -假如现在有一个长度为N的数组: - -![image-20220907154254943](https://s2.loli.net/2022/09/07/oeEcWx91qHp2iXa.png) - -我们每次都随机从数组中挑一个元素,与随机的一个元素进行交换: - -![image-20220907154428792](https://s2.loli.net/2022/09/07/PQKGfMTH4xCUNgt.png) - -只要运气足够好,那么说不定几次就可以搞定,要是运气不好,说不定等到你孙子都结婚了都还没排好。 - -代码如下: - -```c -_Bool checkOrder(int arr[], int size){ - for (int i = 0; i < size - 1; ++i) - if(arr[i] > arr[i + 1]) return 0; - return 1; -} - -int main(){ - int arr[] = {3,5, 7,2, 9, 0, 6,1, 8, 4}, size = 10; - - int counter = 0; - while (1) { - int a = rand() % size, b = rand() % size; - swap(&arr[a], &arr[b]); - if(checkOrder(arr, size)) break; - counter++; - } - printf("在第 %d 次排序完成!", counter); -} -``` - -可以看到在10个元素的情况下,这边第7485618次排序成功了: - -![image-20220907160219493](https://s2.loli.net/2022/09/07/NposWDHJIxuVztO.png) - -但是不知道为什么每次排序出来的结果都是一样的,可能是随机数取得还不够随机吧。 - -| 排序算法 | 最好情况 | 最坏情况 | 空间复杂度 | 稳定性 | -| :------: | :------: | :------: | :--------: | :----: | -| 猴子排序 | $O(1)$ | ∞ | $O(1)$ | 不稳定 | - diff --git a/青空笔记/数据结构笔记/数据结构与算法(四).md b/青空笔记/数据结构笔记/数据结构与算法(四).md deleted file mode 100644 index d80555a..0000000 --- a/青空笔记/数据结构笔记/数据结构与算法(四).md +++ /dev/null @@ -1,1209 +0,0 @@ -![image-20220818210450592](https://s2.loli.net/2022/08/18/sMHy2Ga3bqwuDBY.png) - -# 图结构篇 - -图结构在我们的生活中实际上是非常常见的,其中最显著的就是我们的地图了,比如我的家乡重庆: - -![image-20220821222600741](https://s2.loli.net/2022/09/04/HFR6kX18NnjGx94.png) - -可以看到,地图盘根错节,错综复杂,不同的道路相互连接,我们可以自由地从这些道路通过,从一个地点到达另一个地点。当然除了地图,我们的计算机网络、你的人际关系网等等,这些都可以用图结构来表示。图结构也是整个数据结构中比较难的一部分,而这一章,我们将探讨图结构的性质与应用。 - -图也是由多个结点连接而成的,但是一个结点可以同时连接多个其他结点,多个结点也可以同时指向一个结点,跟我们之前讲解的树结构不同,它是一种多对多的关系: - -![image-20220821223128857](https://s2.loli.net/2022/08/21/zGfXODMAVc7aH34.png) - -它比树形结构更加复杂,没有明确的层次关系,结点与结点之间的连接关系更加自由,图结构是**任意两个数据对象之间都有可能存在某种特定关系**的数据结构。 - -## 基本概念 - -图(Graph)一般由两个集合共同构成,一个是非空但是有限的顶点集合V(Vertex),另一个是描述顶点之间连接关系的边集合E(Edge,边集合可以为空集,比如只有一个顶点的情况下,没得连啊),一个图实际上正是由这些结点(顶点)和对应的边组成的。因此,图可以表示为:$G = (V, E)$ - -比如一个图我们可以表示为,集合$V = \{A,B,C,D\}$,集合$E = \{(A,B),(B,C),(C,D),(D,A),(C,A)\}$,图有两种基本形式,一种是上面那样的有向图(有向图表明了方向,从哪个点到哪个点),还有一种是无向图(无向图仅仅是连接,并不指明方向),比如我们上面这样表示就是一个无向图: - -![image-20220822101619660](https://s2.loli.net/2022/08/22/nqjEmh5YyJlkFZQ.png) - -每个结点的度就是与其连接的边数,每条边是可以包含权值的,当前也可以不包含。 - -当然我们也可以将其表示为有向图,集合$V = \{A,B,C,D\}$,集合$E = \{,,,,\}$注意有向图的边使用尖括号<>表示。比如上面这个有向图,那么就长这样: - -![image-20220822104015728](https://s2.loli.net/2022/08/22/V9BuJt72QH5SEb3.png) - -如果是无向图的一条边(A,B),那么就称A、B互为邻接点;如果是有向图的一条边,那么就称起点A邻接到终点B。有向图的每个结点分为入度和出度,其中入度就是与顶点相连且指向该顶点的边的个数,出度就是从该顶点指向邻接顶点的边的个数。 - -只要我们的图中不出现自回路边或是重边,那么我们就可以称这个图为简单图,比如上面两张图都是简单图。而下面的则是典型的非简单图了,其中图一出现了自回路,而图二出现了重边: - -![image-20220822112214106](https://s2.loli.net/2022/08/22/JSr2lIKfZ7X9OeR.png) - -如果在一个无向图中,任意两个顶点都有一条边相连,则称该图为**无向完全图**: - -![image-20220822121243988](https://s2.loli.net/2022/08/22/G6tJfjZpaNsx5gE.png) - -同样的,在一个有向图中,如果任意两顶点之间都有由方向互为相反的两条边连接,则称该图为**有向完全图**: - -![image-20220822113126420](https://s2.loli.net/2022/08/22/obs24zGhCKmS6Fu.png) - -图通过边将顶点相连,这样我们就可以从一个顶点经过某条路径到达其他顶点了,比如我们现在想要从下面的V5点到达V1点: - -![image-20220822205354964](https://s2.loli.net/2022/08/22/1hTPvxCscLg2SKy.png) - -那么我们可以有很多种路线,比如经过V2到达,经过V3到达等: - -![image-20220822205824613](https://s2.loli.net/2022/08/22/dsMRoAJiBxVCTju.png) - -在一个无向图中,如果从一个顶点到另一个顶点有路径,那么就称这两个顶点是连通的。可以看到,要从V5到达V1我们可以有很多种选择,从V5可以到达V1(当然也可以反着来),所以,我们称V5和V1连通的。特别的,如果图中任意两点都是连通的,那么我们就称这个图为**连通图**。对于有向图,如果图中任意顶点A和B,既有从A到B的路径,也有B到A的路径,则称该有向图是**强连通图**。 - -对于图 $G = (V, E)$ 和 $G' = (V', E')$,若满足 $V'$ 是 $V$ 的子集,并且 $E'$ 是 $E$ 的子集,则称 $G'$ 是 $G$ 的**子图**,比如下面的两个图: - -![image-20220822212041079](https://s2.loli.net/2022/08/22/5hLlIVNf1o4BRuM.png) - -其中右边的图就满足上述性质,所以说右边的图是左边图的子图。 - -无向图的极大连通子图称为**连通分量**,有向图的极大连通子图称为**强连通分量**。那么什么是极大连通子图呢?首先连通子图就是原图的子图,并且子图也是连通图,同时应该具有最大的顶点数,即再加入原图中的其他顶点会导致子图不连通,拥有极大顶点数的同时也要包含依附于这点顶点所有的边才行,比如: - -![image-20220822214010526](https://s2.loli.net/2022/08/22/jlUfrcTXNPYGvOR.png) - -可以看到右侧图1、2、3都是左图的子图,但是它们并不都是原图的连通分量,首先我们来看图1,它也是一个连通图,并且包含极大顶点数和所有的边(也就是原图内部的这一块)所以说它是连通分量,我们接着来看图2,它虽然也是连通图,但是并没有包含极大顶点数(最多可以吧D也给加上,但是这里没加)所以说并不是。最后来看图3,它也是连通图,并且包含了极大顶点数和边,所以说是连通分量。 - -* 原图为连通图,那么连通分量就是其本身,有且仅有一个。 -* 原图为非连通图,那么连通分量会有多个。 - -对于极小连通子图,我们会在后面的生成树部分进行讲解。 - -*** - -## 存储结构 - -前面我们介绍了图的一些基本概念,我们接着来看如何在程序中对图结构进行表示,这一部分可能会涉及到某些在《线性代数》这门课程中出现的概念。 - -### 邻接矩阵 - -邻接矩阵实际上就是用矩阵去表示图中各顶点之间的邻接关系和权值。假设有一个图 $G = (V, E)$,其中有N个顶点,那么我们就可以使用一个N×N的矩阵来表示,比如下面有A、B、C、D四个顶点的图: - -![image-20220822104015728](https://s2.loli.net/2022/08/22/V9BuJt72QH5SEb3.png) - -此时我们需要使用邻接矩阵来表示它,就像下面这样: - -![image-20220822220549501](https://s2.loli.net/2022/08/22/PjTo236CahYf1DZ.png) - -对于一个不带权值的图来说: -$$ -G_{ij} = \begin{cases} - 1, 无向图的(v_i,v_j)或有向图的是图中的边\\ - 0, 无向图的(v_i,v_j)或有向图的不是图中的边 - \end{cases} -$$ -对于一个带权值的图来说,如果有边,则直接填写对应边的权值,如果没有,那么就填写0或是∞(因为某些图会认为0也是权值,所以说可以用∞,它可以是一个计算机允许的最大值大于所有边的权值的数)来进行表示: -$$ -G_{ij} = \begin{cases} - w_{ij}, 无向图的(v_i,v_j)或有向图的是图中的边\\ - 0或∞, 无向图的(v_i,v_j)或有向图的不是图中的边 - \end{cases} -$$ -所以说,对于上面的有向图,我们应该像这样填写: - -![image-20220822221214967](https://s2.loli.net/2022/08/22/AzuomNgXr78TFDp.png) - -那么我们来看看无向图的邻接矩阵呢?比如下面的这个图: - -![image-20220822101619660](https://s2.loli.net/2022/08/22/nqjEmh5YyJlkFZQ.png) - -对于无向图来说,一条边两边是相互连接的,所以说,A连接B,那么B也连接A,所以说就像这样: - -![image-20220822222331925](/Users/nagocoler/Library/Application Support/typora-user-images/image-20220822222331925.png) - -可以看到得到的矩阵用我们在《线性代数》中的定义来说就是一个对称矩阵(上半和下半都是一样的)因为没有自回路顶点,所以说主对角线上的元素全都是`0`。由于无向图没有方向之分,顶点之间是相互连接的,所以说无向图的邻接矩阵必定是一个对称矩阵。 - -我们可以来总结一下性质: - -* 无向图的邻接矩阵一定是一个对称矩阵,因此,有时为了节省时间,我们可以只存放上半部分。 -* 对于无向图,邻接矩阵的第`i`行非0(或非∞)的个数就是第`i`个顶点的度。 -* 对于有向图,邻接矩阵的第`i`行非0(或非∞)的个数就是第`i`个顶点的出度(纵向就是入度了) - -接着我们来看看如何通过代码实现,首先我们需要对结构体进行一下定义,这里我们以有向图为例: - -```c -#define MaxVertex 5 - -typedef char E; //顶点存放的数据类型,这个不用我多说了吧 - -typedef struct MatrixGraph { - int vertexCount; //顶点数 - int edgeCount; //边数 - int matrix[MaxVertex][MaxVertex]; //邻接矩阵 - E data[MaxVertex]; //各个顶点对应的数据 -} * Graph; -``` - -接着我们可以对其进行一下初始化创建后返回: - -```c -Graph create(){ //创建时,我们可以指定图中初始有多少个结点 - Graph graph = malloc(sizeof(struct MatrixGraph)); - graph->vertexCount = 0; //顶点和边数肯定一开始是0 - graph->edgeCount = 0; - for (int i = 0; i < MaxVertex; ++i) //记得把矩阵每个位置都置为0 - for (int j = 0; j < MaxVertex; ++j) - graph->matrix[i][j] = 0; - return graph; -} - -int main(){ - Graph graph = create(); //这里咱们就搞一个 - -} -``` - -接着我们就可以编写一下添加顶点和添加边的函数了: - -```c -void addVertex(Graph graph, E element){ - if(graph->vertexCount >= MaxVertex) return; - graph->data[graph->vertexCount++] = element; //添加新元素 -} - -void addEdge(Graph graph, int a, int b){ //添加几号顶点到几号顶点的边 - if(graph->matrix[a][b] == 0) { - graph->matrix[a][b] = 1; //注意如果是无向图的话,需要[a][b]和[b][a]都置为1 - graph->edgeCount++; - } -} -``` - -我们来尝试构建一下这个有向图: - -![image-20220822104015728](https://s2.loli.net/2022/08/22/V9BuJt72QH5SEb3.png) - -```c -Graph graph = create(); -for (int c = 'A'; c <= 'D' ; ++c) - addVertex(graph, (char) c); -addEdge(graph, 0, 1); //A -> B -addEdge(graph, 1, 2); //B -> C -addEdge(graph, 2, 3); //C -> D -addEdge(graph, 3, 0); //D -> A -addEdge(graph, 2, 0); //C -> A -``` - -接着我们打印此领接矩阵,看看是否变成了我们预想中的那样: - -```c -void printGraph(Graph graph){ - for (int i = -1; i < graph->vertexCount; ++i) { - for (int j = -1; j < graph->vertexCount; ++j) { - if(j == -1) - printf("%c", 'A' + i); - else if(i == -1) - printf("%3c", 'A' + j); - else - printf("%3d", graph->matrix[i][j]); - } - putchar('\n'); - } -} -``` - -最后得到: - -![image-20220830123847943](https://s2.loli.net/2022/08/30/1XnqfOBEHd4DPTk.png) - -可以看到结果跟我们上面推导得出的邻接矩阵一模一样,当然这里仅仅是演示了普通的有向图,我们也可以稍微将代码进行修改,将其变成一个无向图或是带权有向图,这里就不做演示了。 - -### 邻接表 - -前面我们介绍了领接矩阵,我们可以使用邻接矩阵在程序中保存一个图的边相关信息,它采用二维数组的形式,将对应边的连接关系进行存储,但是我们知道,数组存在容量上的局限性(学了这么多节课了,应该能体会到,数组需要一段连续空间,无论是申请还是用起来都很麻烦)同时,我们创建邻接矩阵后,如果图的边数较多(稠密图)利用率还是挺高的,但是一旦遇到边数很少的图(稀疏图)那么表中大量的位置实际上都是`0`,根本没有被利用起来,是很浪费的。 - -此时,我们可以考虑使用链式结构来解决这种问题,就像下面这样: - -![image-20220830125309778](https://s2.loli.net/2022/08/30/2H68yAbFS9GnKD4.png) - -对于图中的每个顶点,建立一个数组,存放一个头结点,我们将与其邻接的顶点,通过一个链表进行记录(看着挺像前面讲的哈希表)这样,也可以表示一个图的连接关系,并且内存空间能够得到更加有效的利用。当然,对于无向图来说,跟之前一样,两边都需要进行保存: - -![image-20220830141940278](https://s2.loli.net/2022/08/30/vJ65hqwpuzRLs1O.png) - -我们来尝试编写一下代码实现,首先还是定义: - -```c -#define MaxVertex 5 - -typedef char E; - -typedef struct Node { //结点和头结点分开定义,普通结点记录邻接顶点信息 - int nextVertex; - struct Node * next; -} * Node; - -struct HeadNode { //头结点记录元素 - E element; - struct Node * next; -}; - -typedef struct AdjacencyGraph { - int vertexCount; //顶点数 - int edgeCount; //边数 - struct HeadNode vertex[MaxVertex]; -} * Graph; -``` - -接着是对其进行初始化: - -```c -Graph create(){ //创建时,我们可以指定图中初始有多少个结点 - Graph graph = malloc(sizeof(struct AdjacencyGraph)); - graph->vertexCount = graph->edgeCount = 0; - return graph; //头结点数组一开始可以不用管 -} -``` - -在添加边和顶点时,稍微麻烦一些: - -```c -void addVertex(Graph graph, E element){ - if(graph->vertexCount >= MaxVertex) return; //跟之前一样 - graph->vertex[graph->vertexCount].element = element; //添加新结点时,再来修改也行 - graph->vertex[graph->vertexCount].next = NULL; - graph->vertexCount++; -} - -void addEdge(Graph graph, int a, int b){ - Node node = graph->vertex[a].next; - Node newNode = malloc(sizeof(struct Node)); - newNode->next = NULL; - newNode->nextVertex = b; - if(!node) { //如果头结点下一个都没有,那么直接连上去 - graph->vertex[a].next = newNode; - } else { //否则说明当前顶点已经连接了至少一个其他顶点了,有可能会出现已经连接过的情况,所以说要特别处理一下 - do { - if(node->nextVertex == b) return; //如果已经连接了对应的顶点,那么直接返回 - if(node->next) node = node->next; //否则继续向后遍历 - else break; //如果没有下一个了,那就找到最后一个结点了,直接结束 - } while (1); - node->next = newNode; - } - graph->edgeCount++; //边数计数+1 -} -``` - -我们来将其构建一下吧,还是以上面的图为例: - -![image-20220822104015728](https://s2.loli.net/2022/08/22/V9BuJt72QH5SEb3.png) - -```c -int main(){ - Graph graph = create(); - for (int c = 'A'; c <= 'D' ; ++c) - addVertex(graph, (char) c); - addEdge(graph, 0, 1); //A -> B - addEdge(graph, 1, 2); //B -> C - addEdge(graph, 2, 3); //C -> D - addEdge(graph, 3, 0); //D -> A - addEdge(graph, 2, 0); //C -> A - - printGraph(graph); -} -``` - -我们来打印看看效果: - -```c -void printGraph(Graph graph){ - for (int i = 0; i < graph->vertexCount; ++i) { - printf("%d | %c", i, graph->vertex[i].element); - Node node = graph->vertex[i].next; - while (node) { - printf(" -> %d", node->nextVertex); - node = node->next; - } - putchar('\n'); - } -} -``` - -得到结果如下: - -![image-20220830132526621](https://s2.loli.net/2022/08/30/Colcf6k7hpIGTDt.png) - -可以看到结果符合我们的预期。 - -不过虽然这样的方式看上去更加的简单高效,但是会给我们带来一些不必要的麻烦,比如上面创建的领接表,我们只能快速得到某个顶点指向了哪些顶点,也就是只能计算到顶点的出度,但是无法快速计算顶点的入度,只能将所有结点统计之后才能得到入度。所以说在表示有向图时,查找上并没有邻接矩阵来的方便。 - -为了解决这种问题,我们可以建立一个逆领接表,来表示所有指向当前顶点的顶点列表: - -![image-20220830133244446](https://s2.loli.net/2022/08/30/YlAgIUmGdP2Ej3X.png) - -实际上就是反着来而已,通过建立这两个领接表,就能在一定程度上缓解不方便的情况。 - -**图练习题:** - -1. 在一个具有n个顶点的有向图中,若所有顶点的出度之和为s,则所有顶点的入度数之和为? - - A. s B. s - 1 C. s + 1 D. 2s - - *有向图的所有出度实际上就是所有顶点连向其他顶点的边数,对于单个顶点来说,要么是自己指向别人(自己的出度,别人的入度),要么别人指向自己(别人的出度,自己的入度),这东西就是个相对的而已,而这些都可以一律看做出度,所以说所有顶点入度数之和就是所有顶点出度之和,所以选A* - -2. 在一个具有n个顶点的无向完全图中,所含的边数为? - - A. n B. n(n-1) C. n(n - 1)/2 D. n(n + 1)/2 - - *首先回顾一下无向完全图的定义:在一个无向图中,任意两个顶点都有一条边相连,则称该图为无向完全图。既然任意两个顶点都有一个,那么每个结点都会有n-1条与其连接的边,所以说总数为 $n \times (n-1)$ 但是由于是无向图,没有方向之分,所以说需要去掉一半的数量,得到 $\frac {n \times (n-1)} {2}$,选择C* - -3. 若要把n个顶点连接为一个连通图,则至少需要几条边? - - A. n B. n - 1 C. n + 1 D. 2n - - *连通图的定义是,每个顶点至少有一条到达其他顶点的路径,所以说我们只需要找一个最简单能够保证每个结点都有与其相连的就行了,也就是连成一根直线(或者是树)的情况,选择B* - -4. 对于一个具有 n 个顶点和 e 条边的无向图,在其对应的邻接表中,所含边结点有多少个? - - A. n B. ne C. e D. 2e - - *对于无向图,这结点个数等于边数的两倍,对于有向图,刚好等于边数,所以说选择 D* - -*** - -## 图的遍历 - -记得小时候每次去书店,都能看到迷宫书: - -![image-20220831141620073](https://s2.loli.net/2022/08/31/NR1W9HQVnI5rZj8.png) - -每次看到都想买一本,但是当时家里条件并不允许消费这么贵的书,所以都只能在书店多看几眼再回去。迷宫的解,实际上就是我们在一个复杂的地图中寻找一条能够从起点到达终点的路径。可以看到从起点开始,每到一个路口,可能都会出现多个分叉,可能有的分叉就会走进死胡同,有的分叉就会走到下一个路口。 - -那么我们人脑是怎么去寻找到正确的路径呢? - -![image-20220831142540478](https://s2.loli.net/2022/08/31/t7RFJSnBdu2pxcL.png) - -我们首先还是会从起点开始看,我们会尝试去走分叉路的每一个方向,如果遇到死胡同,那么我们就退回到上一个路口,再去尝试其他方向,直到能一直往下走为止。经过不断重复上述的操作,最后我们就肯定能够到达迷宫的出口了。 - -而图的搜索,实际上也是类似于迷宫这样的形式,我们需要从图的某一个顶点出发,去寻找到图中对应顶点的位置,这一部分,我们将对图的搜索算法进行讨论。 - -![image-20220831144250794](https://s2.loli.net/2022/08/31/WYxKZwDMtej1JXv.png) - -### 深度优先搜索(DFS) - -我们之前在学习二叉树的过程中,讲解了树的前序遍历,各位回想一下,我们当时是如何在进行遍历的? - -![image-20220814145531577](https://s2.loli.net/2022/08/14/ZRjFywa6kWHrbJY.png) - -前序遍历就是勇往直前,直接走到底,然后再回去走其他的分支,而我们的图其实也可以像这样,我们可以一路向前,如果到了死胡同,那么就倒回去再走其他的方向,如果所有方向都走不通,继续再回到上一个路口(实际上就是我们人脑的思维)这样不断的寻找,肯定是可以找到的。 - -比如现在我们要从A开始寻找下图中的I: - -![image-20220831145024885](https://s2.loli.net/2022/08/31/XgN2k3Ce9VnUDaR.png) - -那么我们的路线可以是这样的: - -![image-20220831145204170](https://s2.loli.net/2022/08/31/XO93w5N6tEMIhFZ.png) - -此时顶点B有三个方向,那么我们可以先随便选一个方向(当然,一般情况下为了规范,推荐按照字母排列顺序来走,这里为了演示,就随便走了)看看: - -![image-20220831145313492](https://s2.loli.net/2022/08/31/gFIJDkKEOe4bzl5.png) - -此时来到K,我们发现K已经是一个死胡同,没有其他路了,那么此时我们就需要回到上一个路口,继续去探索其他的路径: - -![image-20220831145530501](https://s2.loli.net/2022/08/31/NSUDtQTRZfoBnuY.png) - -此时我们接着往下一个相邻的顶点G走,发现G有其他的分叉,那么我们就继续向前: - -![image-20220831145910420](https://s2.loli.net/2022/08/31/sp39cE8yhT54F1R.png) - -此时走到F发现又是死路,那么退回到G,走其他的方向: - -![image-20220831150008288](https://s2.loli.net/2022/08/31/a8I7smtX6PK3NVe.png) - -运气太垃了,又到死胡同了,同样的,回到G继续走其他方向: - -![image-20220831150236884](https://s2.loli.net/2022/08/31/o8MSFfkiHe1ptGV.png) - -走到C之后,我们有其他的路,我们继续往后走: - -![image-20220831150354010](https://s2.loli.net/2022/08/31/v4rlfJdkaWCIcOi.png) - -此时走到顶点H,发现H只有一条路,并且H再向前是已经走过的顶点B,那么此时不能再向前了,所以说直接退回到C,走另一边: - -![image-20220831150617828](https://s2.loli.net/2022/08/31/JCNUjlfpOgbkLIQ.png) - -此时来到E,又有两条路,那么继续随便选一条走: - -![image-20220831150820472](https://s2.loli.net/2022/08/31/IK1AZgGjXr3LqJm.png) - -此时来到顶点J,发现又是死胡同,退回到E,继续走另一边: - -![image-20220831150913443](https://s2.loli.net/2022/08/31/3PfzdGYI6Dv745x.png) - -好了,经过了这么多试错,终于是找到了I顶点,这种方式就是深度优先搜索了。 - -那么我们就来打个代码玩玩吧,这里我们构建一个简单一点的图: - -![image-20220831152924911](https://s2.loli.net/2022/08/31/m7lHM3zQvoRs8Uw.png) - -这里我们使用邻接表表示图,因为邻接表直接保存相邻顶点,所以说到达顶点时遍历相邻顶点会更快(能够到达 $O(V + E)$ 线性阶)而如果使用邻接矩阵的话,我们得完整遍历整个二维数组,就比较费时间了(需要 $O(V^2)$ 平方阶)。 - -比如现在我们想从A开始查找顶点F,首先先把图给建好(注意有6个顶点,记得容量写好): - -```c -int main(){ - Graph graph = create(); - for (int c = 'A'; c <= 'F' ; ++c) - addVertex(graph, (char) c); - addEdge(graph, 0, 1); //A -> B - addEdge(graph, 1, 2); //B -> C - addEdge(graph, 1, 3); //B -> D - addEdge(graph, 1, 4); //D -> E - addEdge(graph, 4, 5); //E -> F - - printGraph(graph); -} -``` - -![image-20220831154358394](https://s2.loli.net/2022/08/31/gS3uycojfd4GBDT.png) - -然后就是我们的深度优先搜索算法了: - -```c -/** - * 深度优先搜索算法 - * @param graph 图 - * @param startVertex 起点顶点下标 - * @param targetVertex 目标顶点下标 - * @param visited 已到达过的顶点数组 - */ -void dfs(Graph graph, int startVertex, int targetVertex, int * visited){ - -} -``` - -我们先将深度优先遍历写出来: - -```c -/** - * 深度优先搜索算法(无向图和有向图都适用) - * @param graph 图 - * @param startVertex 起点顶点下标 - * @param targetVertex 目标顶点下标 - * @param visited 已到达过的顶点数组 - */ -void dfs(Graph graph, int startVertex, int targetVertex, int * visited) { - visited[startVertex] = 1; //走过之后一定记得mark一下 - printf("%c -> ", graph->vertex[startVertex].element); //打印当前顶点值 - Node node = graph->vertex[startVertex].next; //遍历当前顶点所有的分支 - while (node) { - if(!visited[node->nextVertex]) //如果已经到过(有可能是走其他分支到过,或是回头路)那就不继续了 - dfs(graph, node->nextVertex, targetVertex, visited); //没到过就继续往下走,这里将startVertex设定为对于分支的下一个顶点,按照同样的方式去寻找 - node = node->next; - } -} - -int main(){ - ... - - int arr[graph->vertexCount]; - for (int i = 0; i < graph->vertexCount; ++i) arr[i] = 0; - dfs(graph, 0, 5, arr); -} -``` - -深度优先遍历结果如下: - -![image-20220831163728799](https://s2.loli.net/2022/08/31/TNwzR2d5ZGt7Sau.png) - -路线如下: - -![image-20220831163909522](https://s2.loli.net/2022/08/31/RZE9zF54cQlYAg8.png) - -现在我们将需要查找的顶点进行判断: - -```c -/** - * 深度优先搜索 - * @param graph 图 - * @param startVertex 起点顶点下标 - * @param targetVertex 目标顶点下标 - * @param visited 已到达过的顶点数组 - * @return 搜索结果,如果找到返回1,没找到返回0 - */ -_Bool dfs(Graph graph, int startVertex, int targetVertex, int * visited) { - visited[startVertex] = 1; - printf("%c -> ", graph->vertex[startVertex].element); - if(startVertex == targetVertex) return 1; //如果当前顶点就是要找的顶点,直接返回 - Node node = graph->vertex[startVertex].next; - while (node) { - if(!visited[node->nextVertex]) - if(dfs(graph, node->nextVertex, targetVertex, visited)) //如果查找成功,直接返回1,不用再看其他分支了 - return 1; - node = node->next; - } - return 0; //while结束那肯定是没找到了,直接返回0 -} - -int main(){ - ... - - int arr[graph->vertexCount]; - for (int i = 0; i < graph->vertexCount; ++i) arr[i] = 0; - printf("\n%d", dfs(graph, 0, 5, arr)); -} -``` - -得到结果如下: - -![image-20220831164615659](https://s2.loli.net/2022/08/31/xAHsYRfzStMwvGT.png) - -再来找一下顶点D呢: - -![image-20220831164641467](https://s2.loli.net/2022/08/31/Gwf1XhDIPT3mUzv.png) - -可以看到到D之后就停止了,因为已经找到了。那么要是去寻找一个没有连接到图中的结点呢? - -![image-20220831164739301](https://s2.loli.net/2022/08/31/luIONkfmKYPCG2V.png) - -可以看到整个图按照深度优先遍历找完了都没找到。 - -### 广度优先搜索(BFS) - -前面我们介绍了深度优先搜索,我们接着来看另一种方案。还记得我们在前面二叉树中学习的层序遍历吗? - -![image-20220831165617419](https://s2.loli.net/2022/08/31/hwiEoZ9OM2Fqv47.png) - -层序遍历实际上是优先将每一层进行遍历,而不是像前序遍历那样勇往直前,而图的搜索其实也可以采用这种方案,我们可以先探索顶点所有的分支,然后再依次去看这些分支的所有分支: - -![image-20220831170114857](https://s2.loli.net/2022/08/31/qm3OUZbv8XzFLiJ.png) - -首先咱还是从A来到B,此时B有三条分叉路,我们依次访问这三条路的各个顶点: - -![image-20220831172011576](https://s2.loli.net/2022/08/31/SCeXgptNDbdFkLi.png) - -我们先记录一下这三个顶点,同样需要使用队列来完成:H、G、K - -注意访问之后不要再继续向下了,接着我们从这三个里面的第一个顶点H开始,按照同样的方法继续: - -![image-20220831172153888](https://s2.loli.net/2022/08/31/t8c2KVLZM6qx4ui.png) - -此时因为只有一个分支,所以说找到C,继续记录,将C也添加进去:G、K、C - -注意此时需要回去,继续看之前三个顶点的第二个顶点G: - -![image-20220831172312762](https://s2.loli.net/2022/08/31/Y5qFPAbanH4VcuM.png) - -此时C已经看过了,接着就找到了F和D,也是记录一下:K、C、F、D - -然后,我们继续看之前三个结点的最后一个: - -![image-20220831172726616](https://s2.loli.net/2022/08/31/3yfDGmKzLbBhcsA.png) - -此时K已经是死胡同了,那么就结束,然后继续看下一个C: - -![image-20220831172941671](https://s2.loli.net/2022/08/31/4aTCOYzlm3dLGbt.png) - -此时继续将E给记录进去:F、D、E,接着看D和F,也没有后续了,那么最后就只有E了: - -![image-20220831173224689](https://s2.loli.net/2022/08/31/lWyvMUbSdsELVNI.png) - -成功找到目标I顶点,实际上广度优先遍历就是尽可能地扩展范围,多去探索广阔的土地,而不是死拽着一根不放,就像爱情,实在得不到就算了吧,她至始至终就没爱过你,不要继续在她身上浪费感情了,多去结交新的朋友,相信你会遇到更好的。 - -那么按照这个思路,我们就来尝试代码实现一下,首先把队列搬过来: - -```c -typedef int T; //这里将顶点下标作为元素 - -struct QueueNode { - T element; - struct QueueNode * next; -}; - -typedef struct QueueNode * QNode; - -struct Queue{ - QNode front, rear; -}; - -typedef struct Queue * LinkedQueue; - -_Bool initQueue(LinkedQueue queue){ - QNode node = malloc(sizeof(struct QueueNode)); - if(node == NULL) return 0; - queue->front = queue->rear = node; - return 1; -} - -_Bool offerQueue(LinkedQueue queue, T element){ - QNode node = malloc(sizeof(struct QueueNode)); - if(node == NULL) return 0; - node->element = element; - queue->rear->next = node; - queue->rear = node; - return 1; -} - -_Bool isEmpty(LinkedQueue queue){ - return queue->front == queue->rear; -} - -T pollQueue(LinkedQueue queue){ - T e = queue->front->next->element; - QNode node = queue->front->next; - queue->front->next = queue->front->next->next; - if(queue->rear == node) queue->rear = queue->front; - free(node); - return e; -} -``` - -我们还是以上面的图为例: - -![image-20220831152924911](https://s2.loli.net/2022/08/31/m7lHM3zQvoRs8Uw.png) - -```c -/** - * 广度优先遍历 - * @param graph 图 - * @param startVertex 起点顶点下标 - * @param targetVertex 目标顶点下标 - * @param visited 已到达过的顶点数组 - * @param queue 辅助队列 - */ -void bfs(Graph graph, int startVertex, int targetVertex, int * visited, LinkedQueue queue) { - offerQueue(queue, startVertex); //首先把起始位置顶点丢进去 - visited[startVertex] = 1; //起始位置设置为已走过 - while (!isEmpty(queue)) { - int next = pollQueue(queue); - printf("%c -> ", graph->vertex[next].element); //从队列中取出下一个顶点,打印 - Node node = graph->vertex[next].next; //同样的,把每一个分支都遍历一下 - while (node) { - if(!visited[node->nextVertex]) { //如果没有走过,那么就直接入队 - offerQueue(queue, node->nextVertex); - visited[node->nextVertex] = 1; //入队时就需要设定为1了 - } - node = node->next; - } - } -} -``` - -我们来测试一下吧: - -```c -int main(){ - ... - - int arr[graph->vertexCount]; - struct Queue queue; - initQueue(&queue); - for (int i = 0; i < graph->vertexCount; ++i) arr[i] = 0; - bfs(graph, 0, 5, arr, &queue); -} -``` - -成功得到结果: - -![image-20220831184445728](https://s2.loli.net/2022/08/31/5Mxt2czgTkoUQ9p.png) - -如果要指定查找的话,就更简单了: - -```c -_Bool bfs(Graph graph, int startVertex, int targetVertex, int * visited, LinkedQueue queue) { - offerQueue(queue, startVertex); - visited[startVertex] = 1; - while (!isEmpty(queue)) { - int next = pollQueue(queue); - printf("%c -> ", graph->vertex[next].element); - Node node = graph->vertex[next].next; - while (node) { - if(node->nextVertex == targetVertex) return 1; //如果就是我们要找的,直接返回1 - if(!visited[node->nextVertex]) { - offerQueue(queue, node->nextVertex); - visited[node->nextVertex] = 1; - } - node = node->next; - } - } - return 0; //找完了还没有,那就返回0 -} -``` - -这样,我们就实现了图的广度优先搜索。 - -**图练习题:** - -1. 若一个图的边集为:{(A, B),(A, C),(B, D),(C, F),(D, E),(D, F)},对该图进行深度优先搜索,得到的顶点序列可能是: - - A. ABCFDE B. ACFDEB C. ABDCFE D. ABDFEC - - *这种题直接把图画出来,因为边集是圆括号,说是肯定是一个无向图,图先画出来再说:* - - ![image-20220902112113153](https://s2.loli.net/2022/09/02/WZoIyVGu2n3p5hD.png) - - *因为这四个选项都是A开始的,所以说我们从A开始看,因为A连接了B和C,所以说A后面紧跟B或是C都可以,接着往下看,先看走B的情况,因为B只连接了一个D,所以说选项A直接排除,接着往下看,D链接了E和F,所以说选项C直接排除,此时只有选项D了,我们接着往后看,此时我们走F,紧接着的只有C,D也不满足,所以选择B(当然你怕不稳的话把B选项也推出来就行了)* - -2. 若一个图的边集为:{(A, B),(A, C),(B, D),(C, F),(D, E),(D, F)},对该图进行广度优先搜索,得到的顶点序列可能是: - - A. ABCDEF B. ABCFDE C. ABDCEF D. ACBFDE - - *跟上面是同样的思路,只要各位小伙伴听懂了BFS和DFS的思路,肯定没问题的,选择 D* - -3. 如下图所示的无向连通图,从顶点A开始对该图进行广度优先遍历,得到的顶点序列可能是: - - ![image-20220902110829087](https://s2.loli.net/2022/09/02/bKRp6Qzu5vo3fEw.png) - - *同样的思路,选择D* - -*** - -## 图应用 - -前面我们介绍了图的相关性质,以及图的遍历方式,这一部分我们接着来看图的相关应用。 - -### 生成树和最小生成树 - -在开始讲解最小生成树之前,我们先来回顾一下之前讲解的连通分量。 - -* 对于无向图来说,如果图中任意两点都是连通的,那么我们就称这个图为**连通图**。 -* 对于有向图来说,如果图中任意顶点A和B,既有从A到B的路径,也有B到A的路径,则称该有向图是**强连通图**。 - -而连通分量则要求是某个图的子图(子图可以是只包原图含部分顶点和边的图,也可以就是原图本身,因为定义只是子集,不是真子集),并且子图也要是连通的才可以,还有一个重要条件是必须拥有极大顶点数(能够保证图连通的且包含原图最大的顶点数)并且包含所有依附于这些顶点的边(这个极大更偏向于顶点数的极大),我们就称这个子图为极大连通子图。 - -* 无向图的极大连通子图称为连通分量。 -* 有向图的极大强连通子图称为强连通分量。 - -比如下面的有向图,这个图本身并不是连通的: - -![image-20220903101036333](https://s2.loli.net/2022/09/03/lHEi8GWsIjCFg3D.png) - -其中图1和图2都满足上述条件,都是强连通分量,本身就是连通并且已经到达最大的顶点数和边数了(只要再加入其他的顶点和边就会导致不连通)但是图3并不是子图(A到B的边缺失)并且不是强连通的,所以说不是强连通分量。 - -又比如下面这个无向图,这个图本身也是不连通的: - -![image-20220822214010526](https://s2.loli.net/2022/08/22/jlUfrcTXNPYGvOR.png) - -其中图1和图3都满足条件,都是连通分量,但是图2并没有到达最大的顶点数和边数,所以说不是连通分量。 - -当然上面都是原图不连通的情况,如果原图就是一个连通图,包含其所有顶点和边的子图就已经满足条件了,所以其本身就是一个连通分量;同样的,如果原图就是一个强连通图,那么其本身就是一个强连通分量。 - -总结如下: - -* 如果原图本身不连通,那么其连通分量(强连通分量)不止一个。 -* 如果原图本身连通,那么其连通分量(强连通分量)就是其本身。 - -极大连通子图我们回顾完了,那么我们接着来讨论一下**极小连通子图**。这里的极小主要是说的边数的极小,首先依然要是原图的子图并且是连通的,但是此时要求具有最大的顶点数和最小的边数,也就是说再去掉任意一条边会导致图不连通(直接理解为极大连通子图尽可能去掉能去掉的边就行了) - -针对于极小连通子图,我们一般只讨论无向图(对于有向图,不存在极小强连通子图的说法,因为主要是讨论生成树)我们依然将原图就是连通图和原图不是连通图分开分析,首先是原图本身就是连通图的情况: - -![image-20220901180909877](https://s2.loli.net/2022/09/01/zCap6w2A51rEnkK.png) - -原图本身就是连通图,那么其极大连通子图就是其本身,此时我们需要尽可能去掉那些“不必要”的边,依然能够保证其是连通的,也就是极小连通子图。可以看到右边两幅图,跟左边这幅图包含了同样的顶点数量,但是边数被去掉了一些,并且如果再继续去掉任意一条边的话,那么就会导致不连通,所以说左边两幅图都是右边这幅图的极小连通图(当然,就像上面这样,可能会出现多种方案,极小连通图不唯一) - -我们发现,无论是去掉哪些边的情况,到最后一定是只留下 N-1 条边(其中N是顶点数)每个顶点有且仅有一条路径相连,也就是包含原图全部N个顶点的极小连通子图,我们一般称其为:**生成树**,为什么叫生成树呢,因为结点数和边数正好满足树的定义(且不存在回路的情况),我们可以将其调整为一棵树: - -![image-20220903103444346](https://s2.loli.net/2022/09/03/ldtA9jbE1JXMcWf.png) - -当然,这是原图本身就连通的情况,如果原图本身不连通的话,那么就会出现多个连通分量,此时就会得到一片**生成森林**,森林中的树的数量就是其连通分量的数量。 - -那么我们在程序中要怎么才能得到一个有向图的生成树呢?我们可以使用前面讲解的两种图的遍历方式来进行生成,我们以下图为例,这是一个普通的无向连通图: - -![image-20220903111255127](https://s2.loli.net/2022/09/03/RI5Lrpt8WeOkdZX.png) - -我们如果按照深度优先遍历的方式,从G开始,那么就会得到下面的顺序: - -![image-20220903112122707](https://s2.loli.net/2022/09/03/CrsQhon4wxOAjRm.png) - -按照顺序我们就可以得到一棵生成树: - -![image-20220903112332571](https://s2.loli.net/2022/09/03/k1IctqPrOA6BKgp.png) - -虽然看着很奇怪,但是按照我们的顺序,得到的树就是这样的,可以发现,因为我们的深度优先搜索不会去走那些回头路,相当于直接把哪些导致回路和多余的边给去掉了,最后遍历得到的结果就是一颗生成树了。 - -同样的,我们来看看如果是按照广度优先遍历的方式,又会得到什么结果呢? - -![image-20220903112733812](https://s2.loli.net/2022/09/03/UhRBnocjCL3uD9E.png) - -最后得到的生成树为: - -![image-20220903113108162](https://s2.loli.net/2022/09/03/FKRSnvProHtXegi.png) - -实际上我们发现,在广度优先遍历下得到的生成树,也是按照每一层在进行排列的,非常清晰。当然,因为深度优先遍历和广度优先遍历本身的顺序就不是唯一的,所以最后得到的生成树也不是唯一的。 - -生成树讨论完成之后,我们接着来讨论一下最小生成树,那么这个最小指的是什么最小呢?如果我们给一个无向图的边都加上权值(网图)现在要求生成树边的权值总和最小,我们就称这棵树为**最小生成树**(注意最小生成树不唯一,因为有可能出现多种方案都是最小的情况)比如下面的就是最后得到的最小生成树了: - -![image-20220903113954010](https://s2.loli.net/2022/09/03/BWEzS1YOwDohRdU.png) - -构建最小生成树有两种算法,一种是**普利姆(Prim)**算法,还有一种是**克鲁斯卡尔(Kruskal)**算法,我们先来讨论第一种: - -我们以下图为例: - -![image-20220903142138573](https://s2.loli.net/2022/09/03/c4Xge3QImBdDKYt.png) - -普利姆算法的核心就是从任意一个顶点开始,不断成长为一棵树,每次都会选择尽可能小的方向去进行延伸,比如我们一开始还是从顶点A开始: - -此时与A相连的边有B和E,A的延伸方向有两个,此时我们只需要选择一个最小的就可以了: - -![image-20220903142208537](https://s2.loli.net/2022/09/03/LZ5ho6mxtweRPMg.png) - -此时我们已经构建出了由A、E组成的一棵树,同样的,我们需要去寻找与当前树中A、E顶点相连的所有顶点,包括B、G、H,哪一个最小,那么下一个延伸的就是哪一个,此时发现H和E之间最小,继续延伸: - -![image-20220903142245688](https://s2.loli.net/2022/09/03/zT75jqNVS4RX3bI.png) - -现在已经变成了由A、E、H组成的一棵树,同样的,按照之前的思路继续寻找一个最小的方向进行延伸: - -![image-20220903142413604](https://s2.loli.net/2022/09/03/nJWbov1LZNBR8lX.png) - -继续进行延伸,发现F、K之间最小: - -![image-20220903142558882](https://s2.loli.net/2022/09/03/OyGRMXi9K5fE3zD.png) - -此时K、B和K、D和K、H的权重都是4,其中H顶点已经走过了,不能出现回路,所以说不考虑,此时随便选择K、B或是K、D都可以,不会影响后续结果: - -![image-20220903142829606](https://s2.loli.net/2022/09/03/uxI21o4vnbdNLkB.png) - -此时依然是K、D为最小,所以说直接选择: - -![image-20220903142917096](https://s2.loli.net/2022/09/03/cR2BPhbI5X9jAU3.png) - -紧接着,我们发现最小权重的来到了5,此时权重为5的边有B、E和H、I和B、D,但是由于E、D已经走过,此时直接选择H、I即可: - -![image-20220903143057702](https://s2.loli.net/2022/09/03/Lg5Tf1Y2AQ9hjNk.png) - -接着,我们发现I、G也是5,直接选择即可: - -![image-20220903143509563](https://s2.loli.net/2022/09/03/sgfxIojGRUYdQHc.png) - -然后最小权重此时就是6了,选择H、J和I、J都可以,随便选择一个即可: - -![image-20220903143532060](https://s2.loli.net/2022/09/03/T6BoY4IXa3fKpir.png) - -此时,整个图的所有顶点就遍历完成了,现在我们去掉那些没用被采用的边,得到的结果就是我们的最小生成树了: - -![image-20220903143645249](https://s2.loli.net/2022/09/03/eVyJ6o8xraRvl3d.png) - -虽然样子有点丑,但是把它捋一捋就好了。可以看到省去的边都是尽可能大的边,或是那种导致回路的边,留下的边基本都是权重小的边,得到的就是最小生成树了(注意考试的时候只要按照我们的思路推是肯定没问题的,但是千万要仔细看,不要把边给看漏了,不然会出大问题) - -我们接着来看另一种,克鲁斯卡尔算法,它的核心思想就是我们主动去选择那些小的边,而不是像上面一样被动地扩展延伸。 - -在一开始的时候,直接去掉所有的边,我们从这些边中一个一个选择出来(注意是任意一条边都可以选择,并不是只有选择的顶点旁边才能选择,这个过程中可能会出现多棵树,但是最后一定会连成一棵树的),最后形成一颗最小生成树,假设一开始什么都没选择,被选中的边我们一会用橙色标注: - -![image-20220903144403449](https://s2.loli.net/2022/09/03/qdNyC6We2cZHTog.png) - -首先我们直接找到最小边,K、F,它的权值为2,所以说直接选择就行: - -![image-20220903144533239](https://s2.loli.net/2022/09/03/tTq2NnfJPcvogFG.png) - -紧接着就是F、H的边,权重为3,目前最小的了: - -![image-20220903144828106](https://s2.loli.net/2022/09/03/MtJxvkjZoScT2KH.png) - -此时最小的权重就只有4了,目前有4条边都可以进行选择,但是K、H这条边因为K和H都已经在树中了,所以说不能考虑,其他三条边都是没问题的,我们随便选择一条就行了: - -![image-20220903145239074](https://s2.loli.net/2022/09/03/9vEVncqotmeklr7.png) - -继续选择权重为4的边: - -![image-20220903145321395](https://s2.loli.net/2022/09/03/BXZjrOTYfow51iD.png) - -此时权重就来到了5,那么权重为5的顶点我们也可以随便选择一条,只要不会导致出现回路就行了: - -![image-20220903145431925](https://s2.loli.net/2022/09/03/qUd8hiGnYyAZLo5.png) - -此时连接G、I,我们发现出现了两棵树,没关系的,最后会连成一棵树的,我们继续选择其他权重为5的边: - -![image-20220903145551091](https://s2.loli.net/2022/09/03/hpcVj8HwRJnvaf9.png) - -此时我们选择A、E这条边,然后是H、I这条边,虽然这条边上的H和I顶点都已经在树中了,但是它们并不属于同一棵树,这种情况也是可以连接的,然后我们继续选择权重为6的顶点: - -![image-20220903145828812](https://s2.loli.net/2022/09/03/Sltei9m4GdHXQkW.png) - -此时选择I、J或是H、J都可以(最小生成树不唯一)现在我们已经连接上所有的顶点了,最小生成树构建完成,我们把那些没有选择都边都扔了: - -![image-20220903143645249](https://s2.loli.net/2022/09/03/eVyJ6o8xraRvl3d.png) - -其实无论是哪种算法,最后都能够得到一棵最小生成树,有关实现代码,由于太过复杂,这里就不进行编写了。 - -### 最短路径问题 - -前面我们介绍了最小生成树,通过两种算法就能够从众多的边中选择那些尽可能小的边得到一个权重最小的树,这一块我们将继续讨论最小开销相关的问题。 - -![image-20220903150609366](https://s2.loli.net/2022/09/03/FuxnpoTNezYP9Aa.png) - -地铁线路错综复杂,我们想要从一个站点坐到另一个站点,其实是有很多种方案的,比如我们可以选择少的换乘数放的方案,或是距离近的方案,不同的方案可能坐的站点数就不同,而最后我们出站时,始终是按照从A地点到B地点最小经过的站点数进行收费的(比如从A到B有两种方案,前者要坐11个站,后者要坐7个站,但是最后只会按7个站进行收费),那么这么多线路,我们要如何计算得到一条最短的路径呢? - -我们首先从最简单的**单源最短路径**进行讨论,所谓单源最短路径,就是一个顶点出发,到其他顶点的最短路径,比如下面的这张图: - -![image-20220903153802247](https://s2.loli.net/2022/09/03/6ABvydlgPUqEYIZ.png) - -要解决这种问题,我们可以采用**迪杰斯特拉(Dijkstra)**算法,下面我们来看看迪杰斯特拉算法是如何让计算机来计算最短路径的,它跟普利姆算法求最小生成树有着很多相似之处,我们就从A出发,这里我们需要一个表来记录: - -![image-20220903195351496](https://s2.loli.net/2022/09/03/cU2s1vRGq9SJkpE.png) - -dist这一行记录A到其他顶点的最短路径,path这一行记录的是最短路径所邻接的顶点,我们首先还是从A开始,与A直接相邻的两个分别是B和D,其中B的距离是2,D的距离是5,那么我们就先进行一下记录: - -![image-20220903195723929](https://s2.loli.net/2022/09/03/fxAocwRZFzSYgBb.png) - -因为都是从A过来的,所以说直接记录为A即可,接着我们继续找到当前A路径最短的一个顶点B,此时顶点B可以到达C、D、A,因为不能走回头路,不考虑A,那么目前从A到达C的最短距离就是经过B到达的,相当于A->B加上B->C的距离: - -![image-20220903230103368](https://s2.loli.net/2022/09/03/nbhD6gKrcWHZYwF.png) - -然后我们来看顶点D,此时我们发现,除了A直接到D之外,从B也可以到达D,那么我们就可以比较一下,看是从B到D更短一些,还是从A直接到D更短一些 $min(2 + 2, 5)$ ,通过比较,我们发现从B绕过去会更短一些,只需要4即可,所以说我们将其更新一下: - -![image-20220903230254335](https://s2.loli.net/2022/09/03/wZtX2eCvWciJ7mo.png) - -接着我们继续找到下一个离A最近的顶点D,D与顶点E和J相连,直接更新即可,比如E的最短路径就是相当于是A到D的最短路径加上D到E的路径,D到J也是同理: - -![image-20220903230521739](https://s2.loli.net/2022/09/03/RndcpoWkY6Oe84y.png) - -此时继续找到表中下一个距离A最近的顶点J,J可以到达H或者是E,按照同样的方式,我们看看是从D直接到E更短,还是从J到E更短,进行比较 $min(6 + 3, 8)$ ,得到结果是D直接过去更短,所以说不需要更新。然后H更新为J过去的最短路径: - -![image-20220903231152767](https://s2.loli.net/2022/09/03/d8qoaxZiymTGBlv.png) - -我们接着来看下一个距离A最近的顶点C,此时C可以到达F和E,我们先来看E,还是对其进行比较,如果从C到达E更短,那么就更新为新的值,$min(7 + 4, 8)$,最后仍然是从D到E最短,所以说不变,接着我们把F的值更新一下: - -![image-20220903231449081](https://s2.loli.net/2022/09/03/uknUSC2gK5adZjL.png) - -然后我们来看下一个距离A最近的顶点E,E连接的就比较多了,此时E最短路径是从D过来的,那么我们就不考虑D,我们来依次看看与其相连的C、F、G、H、J(注意这里比较的是从E到这些顶点,之前比较的是从这些顶点到E,不要以为是一样的了) - -* 从E到达顶点C:$min(8 + 4, 7)$,故C继续采用原方案。 -* 从E到达顶点F:$min(8 + 2, 15)$,此时从E到达F路径更短,更新F。 -* 从E到达顶点G:直接更新。 -* 从E到达顶点H:$min(8 + 6, 13)$,故H继续采用原方案。 -* 从E到达顶点J:$min(8 + 3, 6)$,故J继续采用原方案。 - -最后得到: - -![image-20220903232316607](https://s2.loli.net/2022/09/03/Ek7nhJuXtKSZgeo.png) - -我们继续来到下一个离A最近的顶点F,F连接了G和E,但是由于当前最短路径是从E过来的,不能走回头路,所以说直接去看G,比较 $min(10 + 5, 17)$,得到从F到达G会更短,所以说更新G: - -![image-20220903232542904](https://s2.loli.net/2022/09/03/mphBFRsLArDQHwK.png) - -然后我们接着看到下一个最短的顶点H,此时H与G和I相连,我们先来看G,$min(13 + 3, 15)$,维持原方案。然后是I,直接更新即可: - -![image-20220903232752582](https://s2.loli.net/2022/09/03/PlHs4NE8JTiFZad.png) - -虽然此时表已经填完了,但是我们还没有把所有的顶点都遍历完,有可能还会存在更短的路径,所以说别着急,我们还得继续看。此时继续选择下一个离A最近的顶点G,它与E、F、H、I相连,由于其实从F过来的,排除掉F,我们来看看其他三个: - -* 从G到达顶点E:$min(15 + 9, 8)$,显然选择原方案就行。 -* 从G到达顶点H:$min(15 + 3, 13)$,依然是选择原方案更短。 -* 从G到达顶点I:$min(15 + 4, 21)$,从G到达I更短,更新。 - -最后得到: - -![image-20220903233144469](https://s2.loli.net/2022/09/03/RL9XeTnAJBdUNMF.png) - -此时我们来看最后一个顶点I,与其连接的有G和H,因为是从G过来的,直接比较H就行了,$min(19 + 8, 13)$,维持原方案就行,至此,迪杰斯特拉算法结束。最后得到的表,就是最终的A到达各个顶点的最短路径值了,并且根据path这一栏的数据,我们就可以直接推出一条路径出来。 - -当然,这只是解决了**单源最短路径**问题,现在我们将问题的难度提升一下,比如我们现在想要求得图中每一对顶点之间的最短路径,那么该如何进行计算呢?最简单的办法就是,我们可以将所有的顶点都执行一次迪杰斯特拉算法,这样我们就可以求到所有顶点之间的最短距离了。只不过这种方式并不是最好的选择,对于这种问题,我们可以选择**弗洛伊德**(Floyd)算法。 - -比如下面的有向网图(权值别出现负数了,不然要出大问题): - -![image-20220904094948962](https://s2.loli.net/2022/09/04/MdPc7Ew96Ukzxjt.png) - -我们可以很轻松地得到它的邻接矩阵: - -![image-20220904101234641](https://s2.loli.net/2022/09/04/ZQ7KvWeuJ6bdThx.png) - -而弗洛伊德算法则是根据最初的邻接矩阵进行推导得出的。规则如下: - -* 从1开始,一直到n(n就是顶点数)的一个矩阵序列A1、A2、...An,我们需要从最初的邻接矩阵开始,从A1开始不断往后推。 -* 每一轮,我们都会去更新那些非对角线(对角线都是0,更新了还是0,所以说没必要看)、`i`行`i`列以外的元素,判断水平和垂直方向投影的两个元素之和是否比原值小,如果是,那就更新为新的值。迭代公式为:$A_k(i,j)=min(A_{k−1}(i,j), A_{k−1}(i,k)+A_{k−1}(k,j))$ -* 经历n轮后,最后得到的就是最终的最短距离了。 - -我们从第一轮开始,第一轮是基于原有的邻接矩阵来进行处理的: - -![image-20220904102258851](https://s2.loli.net/2022/09/04/czu5FEq8gGsRC97.png) - -此时我们看到,除了对角线以外,就是B->C和C->B的这两个位置,我们按照上面的规则,进行计算: - -![image-20220904102738649](https://s2.loli.net/2022/09/04/i3csRCQaxW17jV9.png) - -同样的,我们继续看到C->B这个为止,按照同样的方式进行更新: - -![image-20220904103010762](https://s2.loli.net/2022/09/04/TsIgShcqrdR3BaQ.png) - -最后更新完成得到的结果如下: - -![image-20220904103033008](https://s2.loli.net/2022/09/04/dObC8f2u6SqX4eT.png) - -实际上我们发现,我们计算的和相当于是绕路的结果与当前直接走的结果相比较得到的。按照的同样的方式,我们开始第二轮: - -![image-20220904103410691](https://s2.loli.net/2022/09/04/tXkEVLqR5dIchWT.png) - -更新完成之后,C->A的距离变成了5: - -![image-20220904103549079](https://s2.loli.net/2022/09/04/b9SGVFR6xEWBtgp.png) - -我们接着来看最后一轮: - -![image-20220904103724239](https://s2.loli.net/2022/09/04/alRYcGDp8xETLn1.png) - -此时我们将A->B的距离也更新一下: - -![image-20220904103815369](https://s2.loli.net/2022/09/04/Qwa2JMLXYBhnFsd.png) - -最后我们得到的矩阵,存放的就是所有顶点之间的最短距离了,当然这里我们只计算了最短距离,没有去记录从哪个方向到达此顶点的,各位小伙伴也可以在计算的同时单独在另一个表中记录一下从哪个顶点过去计算出来的最小距离,这里就不演示了。实际上这个算法对我们来说是更好理解的一种算法,并且在编写程序时也会很简单,我们以下图为例: - -![image-20220904105442929](https://s2.loli.net/2022/09/04/9fuBUwRYav4bghd.png) - -代码如下: - -```c -#define INF 210000000 -#define N 4 - -int min(int a, int b){ - return a > b ? b : a; -} - -void floyd(int matrix[N][N], int n){ - for (int k = 0; k < n; ++k) //一共需要执行K轮 - for (int i = 0; i < n; ++i) //i和j从0开始就行了,直接全看,不会影响结果的 - for (int j = 0; j < n; ++j) - matrix[i][j] = min(matrix[i][k] + matrix[k][j], matrix[i][j]); //按照规则更新就行了 -} - -int main(){ - int matrix[N][N] = {{0, 1, INF, INF}, - {4, 0, INF, 5}, - {INF, 2, 0, INF}, - {3, INF, 7, 0}}; - - floyd(matrix, N); - - for (int i = 0; i < N; ++i) { - for (int j = 0; j < N; ++j) - printf("%d ", matrix[i][j]); - putchar('\n'); - } -} -``` - -最后得到的结果为: - -![image-20220904110149836](https://s2.loli.net/2022/09/04/mDWY8ZzqSipRGFa.png) - -经过对比,确实是最短的路径了。 - -### 拓扑排序 - -我们接着来看**拓扑排序**,实际上我们生活中可能会遇到下面的问题: - -比如我们的大学课程的学习,一些课程开启可能需要修完一些前置课程,比如数据结构开课需要先修完C语言程序设计,Java开课需要修完计算机网络、计算机组成原理等课程,我们在到达某个阶段之前,需要完成一些前置条件才可以解锁。包括我们游戏中的任务,需要先完成哪些主线任务,完成哪些支线任务,才能解锁新的阶段。 - -我们可以将这些任务都看做是一个顶点,最后就能够连接成一个有向图: - -![image-20220904110937920](https://s2.loli.net/2022/09/04/OoPBQgd9WrSJhNx.png) - -因为始终是由前置条件来解锁后续,所以说整个图中是不可以出现循环的(要是有循环的话就没办法继续了,就像先有鸡还是先有蛋的问题一样)所以说构建出来的这种图我们也称为**有向无环图**(DAG),其实按照我们通俗的话来说,它就是个流程图罢了,我们只需要按照这个流程图来进行即可。像这种顶点表示活动或任务的图也称为**AOV图**。 - -**拓扑排序**(Topological Order)是指,将一个有向无环图(Directed Acyclic Graph)进行排序进而得到一个有序的线性序列。 - -比如上图的拓扑排序可以是以下的几种: - -* A,B,C,D,E,F,G,H,I,J -* A,C,D,B,E,F,G,H,I,J -* A,D,C,B,E,F,G,H,I,J -* A,B,D,C,E,F,G,H,I,J - -只要我们保证前置任务在后续任务之前完成即可,前置任务的完成顺序不做要求,所以拓扑排序不唯一。 - -![image-20220904121459739](https://s2.loli.net/2022/09/04/uMWBeLlzh9vHpdC.png) - -那么我们在程序中如何对一个有向无环图进行拓扑排序呢?以上图为例,其实很简单,我们还是利用队列来完成,我们每次只需要将那些入度为0的顶点,丢进队列中(注意丢进去之后记得更新一下图中其他顶点的入度)首先从A: - -![image-20220904122602140](https://s2.loli.net/2022/09/04/7bRdsEGLMqokZCf.png) - -此时队列中有A这个顶点,接着我们来看看图中剩余的顶点,哪些又是入度为0的顶点,可以看到D也是: - -![image-20220904122621668](https://s2.loli.net/2022/09/04/quj1yedrT69OQz3.png) - -当目前所有度数为0的顶点进入队列之后,我们开始出队,正式开始拓扑排序,在出队时直接打印,并且查看,当此顶点离开图之后,图中会不会有其他顶点的入度变为0,如果有,将其他顶点入队。比如此时A出队之后,那么A要从图中移除,现在B也变成了入度为0的顶点,所以说将B丢进队列: - -![image-20220904122914376](https://s2.loli.net/2022/09/04/23gdNArusypQS84.png) - -接着,我们继续让D出队,我们发现D出队之后,E变成了入度为0的顶点,所以说将E入队: - -![image-20220904123206951](https://s2.loli.net/2022/09/04/wjZ4TFslOL69IBA.png) - -接着我们继续出队,B出队之后,我们发现没有任何顶点入度变为0了,所以说不管,继续: - -![image-20220904123257858](https://s2.loli.net/2022/09/04/gWDMYimuxjFN45O.png) - -继续将E出队,在E出队之后,顶点F、C都变成了入度为0的顶点,统统入队: - -![image-20220904123445483](https://s2.loli.net/2022/09/04/uiXtEYK5aIWxNl4.png) - -此时继续将C出队,我们发现没有任何顶点入队变为0,我们继续来看F: - -![image-20220904123544940](https://s2.loli.net/2022/09/04/ue1dkjGcLKg3TCY.png) - -当F出队后,顶点G变成了入度为0的顶点,此时将G入队: - -![image-20220904123635522](https://s2.loli.net/2022/09/04/yqDC2BFQpElfz1L.png) - -剩下就是把G出队,然后F入队F再出队了: - -![image-20220904123742305](https://s2.loli.net/2022/09/04/2Na4UxFBJV6u3eR.png) - -最后得到的拓扑序列为:ADBECFGH,其实思路还是比较简单的,当然,其实我们利用拓扑排序算法可以检测一个有向图是否为有向无环图,也就是说顶点还没遍历完队列就空了的话,说明一定出现了回路。 - -### 关键路径计算 - -经过前面的学习,我们知道一个任务可能会存在前置任务,只不过我们仅仅是简单讨论了任务的完成顺序,如果此时我们为每个任务添加一个权重,表示任务所需要花费的时间,那么我们的后续任务就需要前置任务全部按时间完成之后才能继续: - -![image-20220904130014247](https://s2.loli.net/2022/09/04/uUPRtKrGBbXlNHi.png) - -比如A代表某个任务(事件),B代表另一个任务,我们需要先花费2天时间完成A之后,才能开始B,活动包括时间用边来表示,我们将边作为活动的图称为**AOE图**,每个事件对应着多个活动(多条边)它就像一个大工程一样,从A开始,中间需要经过各种各样的步骤,最后到H完工作为结束。 - -而我们需要计算的是那些最拖延工期的活动,比如要开始任务C,那么需要完成A、B才可以,完成A需要7天,完成B需要5天,由于C需要同时完成A和B才能继续,所以说A就变成了最拖延工期的任务,因为它的时间比B还长,B都完工了,还需要等待A完成才可以。只要计算出这些最拖延工期的任务,得到一条**关键路径**,我们就可以得到完成整个工程最早的时间以及各项任务可以在什么时候开工了。 - -我们来看看如何进行计算,我们以下图为例: - -![image-20220904132328013](https://s2.loli.net/2022/09/04/E8UqCQoylmLMuZA.png) - -我们需要计算两个东西,一个是**事件最早完成时间**(也就是要完成这个事件最快要多久),还有一个是**事件最晚开始时间**(就是这个事件在不影响工期的情况下最晚可以多久开始): - -![image-20220904132930050](https://s2.loli.net/2022/09/04/GeXishvJ5wmKUHW.png) - -我们依然是按照之前的拓扑排序的顺序进行,首先一开始是A,因为只有一个起点A肯定是可以直接开始的,所以说最早和最晚时间都是0(注意如果出现多个起点的话,最晚开始时间就不一定了),我们接着AOE图的工作顺序,来计算任务B和C的最早和最晚时间: - -![image-20220904133246766](https://s2.loli.net/2022/09/04/YFJonUm1x6H5AOl.png) - -接着就是D和E,首先D需要B和C同时完工之后才能继续,那么也就是说需要选择B和C过来时间最长的那一个: - -![image-20220904133658962](https://s2.loli.net/2022/09/04/qo1ZwWgSji45uv3.png) - -最后就是F,到达F一共有三条路径,我们依然是选择最长的那一条,从D过来总共需要8天时间: - -![image-20220904133802361](https://s2.loli.net/2022/09/04/M4UzkX9ePgtSV2c.png) - -故整个工程的最早完成时间为8天,我们接着来看活动的最晚开始时间,现在我们要从终点倒着往回看: - -![image-20220904134114068](https://s2.loli.net/2022/09/04/EsHI4DSYjWAVfvB.png) - -首先终点一定是8,因为工期最快是在8天结束的,我们继续倒着往回走,先来看E,E需要6天才能到达,但是只需要1天就可以结束,所以 8 - 1 = 7,最晚可以在第7天时动工: - -![image-20220904134310369](https://s2.loli.net/2022/09/04/8W2mo5D1vFN9ur6.png) - -然后是D,因为D到F需要2天时间,而D已经是第6天了,总时间8天,所以说D刻不容缓,第6天就需要马上开工: - -![image-20220904134445037](https://s2.loli.net/2022/09/04/mTkRZXpP8necDyf.png) - -然后是C,C比较复杂,因为C有两个活动,一个是指向D的,一个是指向F的,我们需要单独计算每一个活动: - -* C -> F:用F的最晚开始时间减去任务时间 = 8 - 3 = 5,此时C最晚可以从第5天开始。 -* C -> D:用D的最晚开始时间减去任务时间 = 6 - 4 = 2,此时因为C的最早开始时间就是2,所以说C不能晚点开始。 - -综上,C不能晚点开始,只能从第2天就开始,因为要满足D的条件: - -![image-20220904135059487](https://s2.loli.net/2022/09/04/7YbvfGVhlwO1yJR.png) - -最后是B,B也是有两个任务,一个是指向E一个是指向D: - -* B -> E:用E的最晚开始时间减去任务时间 = 7 - 3 = 4,此时B最晚可以第4天开工。 -* B -> D:用D的最晚开始时间减去任务时间 = 6 - 2 = 4,同上。 - -所以,B的最晚开始时间可以是第4天: - -![image-20220904135338214](https://s2.loli.net/2022/09/04/oyInZ68SxJqYm5R.png) - -当然最后我们也可以计算一下A -> B和A -> C,但是由于只有这一个起点,所以说算出来肯定是0,当然如果出现多个起点的情况,还需要进行计算得到的。 - -计算完成之后,我们就可以得到关键路径了,也就是那些最早和最晚时间都一样的顶点(说明是刻不容缓的,时间很紧)这些顶点连成的路线,就是我们要找的关键路径了:A -> C -> D -> F,这条路径被安排的满满当当。关键路径上的所有活动都是**关键活动**,整个工期就是由这些活动在决定的,因此,我们可通过适当加快关键活动来缩短整个项目的工期,但是注意不能加快得太猛,因为如果用力过猛可能会导致关键路径发生变化。当然,关键路径并不是唯一的,可能会出现一样的情况。 - -至此,有关图结构相关的内容,我们就讲解到这里。 - diff --git a/面试/HashMap/HashMap集合(高级).md b/面试(收集)/HashMap/HashMap集合(高级).md similarity index 100% rename from 面试/HashMap/HashMap集合(高级).md rename to 面试(收集)/HashMap/HashMap集合(高级).md diff --git a/面试/HashMap/img/1.bmp b/面试(收集)/HashMap/img/1.bmp similarity index 100% rename from 面试/HashMap/img/1.bmp rename to 面试(收集)/HashMap/img/1.bmp diff --git a/面试/HashMap/img/1.png b/面试(收集)/HashMap/img/1.png similarity index 100% rename from 面试/HashMap/img/1.png rename to 面试(收集)/HashMap/img/1.png diff --git a/面试/HashMap/img/2.bmp b/面试(收集)/HashMap/img/2.bmp similarity index 100% rename from 面试/HashMap/img/2.bmp rename to 面试(收集)/HashMap/img/2.bmp diff --git a/面试/HashMap/img/image-20191114193730911.png b/面试(收集)/HashMap/img/image-20191114193730911.png similarity index 100% rename from 面试/HashMap/img/image-20191114193730911.png rename to 面试(收集)/HashMap/img/image-20191114193730911.png diff --git a/面试/HashMap/img/image-20191114205953147.png b/面试(收集)/HashMap/img/image-20191114205953147.png similarity index 100% rename from 面试/HashMap/img/image-20191114205953147.png rename to 面试(收集)/HashMap/img/image-20191114205953147.png diff --git a/面试/HashMap/img/image-20191115151630786.png b/面试(收集)/HashMap/img/image-20191115151630786.png similarity index 100% rename from 面试/HashMap/img/image-20191115151630786.png rename to 面试(收集)/HashMap/img/image-20191115151630786.png diff --git a/面试/HashMap/img/image-20191115151657917.png b/面试(收集)/HashMap/img/image-20191115151657917.png similarity index 100% rename from 面试/HashMap/img/image-20191115151657917.png rename to 面试(收集)/HashMap/img/image-20191115151657917.png diff --git a/面试/HashMap/img/image-20191115161055901.png b/面试(收集)/HashMap/img/image-20191115161055901.png similarity index 100% rename from 面试/HashMap/img/image-20191115161055901.png rename to 面试(收集)/HashMap/img/image-20191115161055901.png diff --git a/面试/HashMap/img/image-20191115173553375.png b/面试(收集)/HashMap/img/image-20191115173553375.png similarity index 100% rename from 面试/HashMap/img/image-20191115173553375.png rename to 面试(收集)/HashMap/img/image-20191115173553375.png diff --git a/面试/HashMap/img/image-20191117110812839.png b/面试(收集)/HashMap/img/image-20191117110812839.png similarity index 100% rename from 面试/HashMap/img/image-20191117110812839.png rename to 面试(收集)/HashMap/img/image-20191117110812839.png diff --git a/面试/HashMap/img/image-20191117110934974.png b/面试(收集)/HashMap/img/image-20191117110934974.png similarity index 100% rename from 面试/HashMap/img/image-20191117110934974.png rename to 面试(收集)/HashMap/img/image-20191117110934974.png diff --git a/面试/HashMap/img/image-20191117111211630.png b/面试(收集)/HashMap/img/image-20191117111211630.png similarity index 100% rename from 面试/HashMap/img/image-20191117111211630.png rename to 面试(收集)/HashMap/img/image-20191117111211630.png diff --git a/面试/HashMap/img/image-20191117160455507.png b/面试(收集)/HashMap/img/image-20191117160455507.png similarity index 100% rename from 面试/HashMap/img/image-20191117160455507.png rename to 面试(收集)/HashMap/img/image-20191117160455507.png diff --git a/面试/HashMap/img/image-20191117160627369.png b/面试(收集)/HashMap/img/image-20191117160627369.png similarity index 100% rename from 面试/HashMap/img/image-20191117160627369.png rename to 面试(收集)/HashMap/img/image-20191117160627369.png diff --git a/面试/HashMap/img/image-20191117160733756.png b/面试(收集)/HashMap/img/image-20191117160733756.png similarity index 100% rename from 面试/HashMap/img/image-20191117160733756.png rename to 面试(收集)/HashMap/img/image-20191117160733756.png diff --git a/面试/HashMap/img/image-20191117164748836.png b/面试(收集)/HashMap/img/image-20191117164748836.png similarity index 100% rename from 面试/HashMap/img/image-20191117164748836.png rename to 面试(收集)/HashMap/img/image-20191117164748836.png diff --git a/面试/HashMap/img/image-20191117165438726.png b/面试(收集)/HashMap/img/image-20191117165438726.png similarity index 100% rename from 面试/HashMap/img/image-20191117165438726.png rename to 面试(收集)/HashMap/img/image-20191117165438726.png diff --git a/面试/HashMap/img/哈希表.png b/面试(收集)/HashMap/img/哈希表.png similarity index 100% rename from 面试/HashMap/img/哈希表.png rename to 面试(收集)/HashMap/img/哈希表.png diff --git a/面试/HashMap/img/哈希表存储过程.png b/面试(收集)/HashMap/img/哈希表存储过程.png similarity index 100% rename from 面试/HashMap/img/哈希表存储过程.png rename to 面试(收集)/HashMap/img/哈希表存储过程.png diff --git a/面试/Io/BIO、NIO、AIO.md b/面试(收集)/Io/BIO、NIO、AIO.md similarity index 100% rename from 面试/Io/BIO、NIO、AIO.md rename to 面试(收集)/Io/BIO、NIO、AIO.md diff --git a/面试/Io/BIO、NIO、AIO/image-20200223212913139.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200223212913139.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200223212913139.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200223212913139.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200223214123052.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200223214123052.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200223214123052.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200223214123052.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200223214143465.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200223214143465.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200223214143465.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200223214143465.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200223214155975.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200223214155975.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200223214155975.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200223214155975.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200223232406727.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200223232406727.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200223232406727.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200223232406727.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200609143036924.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200609143036924.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200609143036924.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200609143036924.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200609144819885.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200609144819885.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200609144819885.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200609144819885.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200615173640869.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200615173640869.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200615173640869.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200615173640869.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200615173805332.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200615173805332.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200615173805332.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200615173805332.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200615175145231.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200615175145231.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200615175145231.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200615175145231.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200615180407947.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200615180407947.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200615180407947.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200615180407947.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200615180441015.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200615180441015.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200615180441015.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200615180441015.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200615181141593.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200615181141593.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200615181141593.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200615181141593.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200615181255063.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200615181255063.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200615181255063.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200615181255063.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200615182007461.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200615182007461.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200615182007461.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200615182007461.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200618222916021.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200618222916021.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200618222916021.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200618222916021.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200619085953166.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200619085953166.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200619085953166.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200619085953166.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200619123304241.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200619123304241.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200619123304241.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200619123304241.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200619153658139.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200619153658139.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200619153658139.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200619153658139.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200619163952309.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200619163952309.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200619163952309.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200619163952309.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200619171301760.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200619171301760.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200619171301760.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200619171301760.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200619172434538.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200619172434538.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200619172434538.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200619172434538.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200619172501287.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200619172501287.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200619172501287.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200619172501287.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200619172622845.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200619172622845.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200619172622845.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200619172622845.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200619185140064.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200619185140064.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200619185140064.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200619185140064.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200619185232089.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200619185232089.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200619185232089.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200619185232089.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200619185242262.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200619185242262.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200619185242262.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200619185242262.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200619185325095.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200619185325095.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200619185325095.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200619185325095.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200619230211694.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200619230211694.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200619230211694.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200619230211694.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200619230228989.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200619230228989.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200619230228989.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200619230228989.png diff --git a/面试/Io/BIO、NIO、AIO/image-20200619230246145.png b/面试(收集)/Io/BIO、NIO、AIO/image-20200619230246145.png similarity index 100% rename from 面试/Io/BIO、NIO、AIO/image-20200619230246145.png rename to 面试(收集)/Io/BIO、NIO、AIO/image-20200619230246145.png diff --git a/面试/Io/assets/1554106519842.png b/面试(收集)/Io/assets/1554106519842.png similarity index 100% rename from 面试/Io/assets/1554106519842.png rename to 面试(收集)/Io/assets/1554106519842.png diff --git a/面试/Io/assets/1554107378415.png b/面试(收集)/Io/assets/1554107378415.png similarity index 100% rename from 面试/Io/assets/1554107378415.png rename to 面试(收集)/Io/assets/1554107378415.png diff --git a/面试/Io/assets/1554283784624.png b/面试(收集)/Io/assets/1554283784624.png similarity index 100% rename from 面试/Io/assets/1554283784624.png rename to 面试(收集)/Io/assets/1554283784624.png diff --git a/面试/Io/assets/1554283787364.png b/面试(收集)/Io/assets/1554283787364.png similarity index 100% rename from 面试/Io/assets/1554283787364.png rename to 面试(收集)/Io/assets/1554283787364.png diff --git a/面试/Io/assets/1554977023695.png b/面试(收集)/Io/assets/1554977023695.png similarity index 100% rename from 面试/Io/assets/1554977023695.png rename to 面试(收集)/Io/assets/1554977023695.png diff --git a/面试/Io/assets/1554977307393.png b/面试(收集)/Io/assets/1554977307393.png similarity index 100% rename from 面试/Io/assets/1554977307393.png rename to 面试(收集)/Io/assets/1554977307393.png diff --git a/面试/Io/assets/1555058811312.png b/面试(收集)/Io/assets/1555058811312.png similarity index 100% rename from 面试/Io/assets/1555058811312.png rename to 面试(收集)/Io/assets/1555058811312.png diff --git a/面试/Io/assets/1555059022377.png b/面试(收集)/Io/assets/1555059022377.png similarity index 100% rename from 面试/Io/assets/1555059022377.png rename to 面试(收集)/Io/assets/1555059022377.png diff --git a/面试/Io/assets/1555059069147.png b/面试(收集)/Io/assets/1555059069147.png similarity index 100% rename from 面试/Io/assets/1555059069147.png rename to 面试(收集)/Io/assets/1555059069147.png diff --git a/面试/Io/assets/1558406168094.png b/面试(收集)/Io/assets/1558406168094.png similarity index 100% rename from 面试/Io/assets/1558406168094.png rename to 面试(收集)/Io/assets/1558406168094.png diff --git a/面试/Io/assets/1558406228651.png b/面试(收集)/Io/assets/1558406228651.png similarity index 100% rename from 面试/Io/assets/1558406228651.png rename to 面试(收集)/Io/assets/1558406228651.png diff --git a/面试/Io/assets/1558417869317.png b/面试(收集)/Io/assets/1558417869317.png similarity index 100% rename from 面试/Io/assets/1558417869317.png rename to 面试(收集)/Io/assets/1558417869317.png diff --git a/面试/Io/assets/1563781805276.png b/面试(收集)/Io/assets/1563781805276.png similarity index 100% rename from 面试/Io/assets/1563781805276.png rename to 面试(收集)/Io/assets/1563781805276.png diff --git a/面试/Io/assets/1563782705612.png b/面试(收集)/Io/assets/1563782705612.png similarity index 100% rename from 面试/Io/assets/1563782705612.png rename to 面试(收集)/Io/assets/1563782705612.png diff --git a/面试/Io/assets/1564029164023.png b/面试(收集)/Io/assets/1564029164023.png similarity index 100% rename from 面试/Io/assets/1564029164023.png rename to 面试(收集)/Io/assets/1564029164023.png diff --git a/面试/Io/assets/1564034088380.png b/面试(收集)/Io/assets/1564034088380.png similarity index 100% rename from 面试/Io/assets/1564034088380.png rename to 面试(收集)/Io/assets/1564034088380.png diff --git a/面试/Io/assets/1576845063254.png b/面试(收集)/Io/assets/1576845063254.png similarity index 100% rename from 面试/Io/assets/1576845063254.png rename to 面试(收集)/Io/assets/1576845063254.png diff --git a/面试/Io/assets/1576845708347.png b/面试(收集)/Io/assets/1576845708347.png similarity index 100% rename from 面试/Io/assets/1576845708347.png rename to 面试(收集)/Io/assets/1576845708347.png diff --git a/面试/Io/assets/1576846069034.png b/面试(收集)/Io/assets/1576846069034.png similarity index 100% rename from 面试/Io/assets/1576846069034.png rename to 面试(收集)/Io/assets/1576846069034.png diff --git a/面试/Io/assets/1576846149122.png b/面试(收集)/Io/assets/1576846149122.png similarity index 100% rename from 面试/Io/assets/1576846149122.png rename to 面试(收集)/Io/assets/1576846149122.png diff --git a/面试/Io/assets/1576846674791.png b/面试(收集)/Io/assets/1576846674791.png similarity index 100% rename from 面试/Io/assets/1576846674791.png rename to 面试(收集)/Io/assets/1576846674791.png diff --git a/面试/Io/assets/20180506170618574 (1).jpg b/面试(收集)/Io/assets/20180506170618574 (1).jpg similarity index 100% rename from 面试/Io/assets/20180506170618574 (1).jpg rename to 面试(收集)/Io/assets/20180506170618574 (1).jpg diff --git a/面试/Io/assets/20180823105937208-1554108841592.png b/面试(收集)/Io/assets/20180823105937208-1554108841592.png similarity index 100% rename from 面试/Io/assets/20180823105937208-1554108841592.png rename to 面试(收集)/Io/assets/20180823105937208-1554108841592.png diff --git a/面试/Io/assets/20180823105937208.png b/面试(收集)/Io/assets/20180823105937208.png similarity index 100% rename from 面试/Io/assets/20180823105937208.png rename to 面试(收集)/Io/assets/20180823105937208.png diff --git a/面试/Io/assets/20180823111404805-1554111096160.png b/面试(收集)/Io/assets/20180823111404805-1554111096160.png similarity index 100% rename from 面试/Io/assets/20180823111404805-1554111096160.png rename to 面试(收集)/Io/assets/20180823111404805-1554111096160.png diff --git a/面试/Io/assets/20180823111404805.png b/面试(收集)/Io/assets/20180823111404805.png similarity index 100% rename from 面试/Io/assets/20180823111404805.png rename to 面试(收集)/Io/assets/20180823111404805.png diff --git a/面试/Io/assets/795235-20161208175600241-333223016.png b/面试(收集)/Io/assets/795235-20161208175600241-333223016.png similarity index 100% rename from 面试/Io/assets/795235-20161208175600241-333223016.png rename to 面试(收集)/Io/assets/795235-20161208175600241-333223016.png diff --git a/面试/Io/assets/795235-20161208180531866-1294544171.png b/面试(收集)/Io/assets/795235-20161208180531866-1294544171.png similarity index 100% rename from 面试/Io/assets/795235-20161208180531866-1294544171.png rename to 面试(收集)/Io/assets/795235-20161208180531866-1294544171.png diff --git a/面试/Io/assets/795235-20161208182733007-1325239705.png b/面试(收集)/Io/assets/795235-20161208182733007-1325239705.png similarity index 100% rename from 面试/Io/assets/795235-20161208182733007-1325239705.png rename to 面试(收集)/Io/assets/795235-20161208182733007-1325239705.png diff --git a/面试/Io/assets/795235-20161208184254976-2041108433.png b/面试(收集)/Io/assets/795235-20161208184254976-2041108433.png similarity index 100% rename from 面试/Io/assets/795235-20161208184254976-2041108433.png rename to 面试(收集)/Io/assets/795235-20161208184254976-2041108433.png diff --git a/面试/Io/assets/795235-20161208185905304-1734361836.png b/面试(收集)/Io/assets/795235-20161208185905304-1734361836.png similarity index 100% rename from 面试/Io/assets/795235-20161208185905304-1734361836.png rename to 面试(收集)/Io/assets/795235-20161208185905304-1734361836.png diff --git a/面试/Io/assets/795235-20161208185934491-1881555264.png b/面试(收集)/Io/assets/795235-20161208185934491-1881555264.png similarity index 100% rename from 面试/Io/assets/795235-20161208185934491-1881555264.png rename to 面试(收集)/Io/assets/795235-20161208185934491-1881555264.png diff --git a/面试/Io/assets/8cb1cb134954092333fee7009058d109b3de493d.jpg b/面试(收集)/Io/assets/8cb1cb134954092333fee7009058d109b3de493d.jpg similarity index 100% rename from 面试/Io/assets/8cb1cb134954092333fee7009058d109b3de493d.jpg rename to 面试(收集)/Io/assets/8cb1cb134954092333fee7009058d109b3de493d.jpg diff --git a/面试/Io/assets/timg.jpg b/面试(收集)/Io/assets/timg.jpg similarity index 100% rename from 面试/Io/assets/timg.jpg rename to 面试(收集)/Io/assets/timg.jpg diff --git a/面试/面试/JVM相关面试题.md b/面试(收集)/JVM相关面试题.md similarity index 100% rename from 面试/面试/JVM相关面试题.md rename to 面试(收集)/JVM相关面试题.md diff --git a/面试/面试/Java集合相关面试题.md b/面试(收集)/Java集合相关面试题.md similarity index 100% rename from 面试/面试/Java集合相关面试题.md rename to 面试(收集)/Java集合相关面试题.md diff --git a/面试/面试/MySQL面试题-参考回答.md b/面试(收集)/MySQL面试题-参考回答.md similarity index 100% rename from 面试/面试/MySQL面试题-参考回答.md rename to 面试(收集)/MySQL面试题-参考回答.md diff --git a/面试/面试/Redis面试题-参考回答.md b/面试(收集)/Redis面试题-参考回答.md similarity index 100% rename from 面试/面试/Redis面试题-参考回答.md rename to 面试(收集)/Redis面试题-参考回答.md diff --git a/面试/面试/img/20161222153407_471.png b/面试(收集)/img/20161222153407_471.png similarity index 100% rename from 面试/面试/img/20161222153407_471.png rename to 面试(收集)/img/20161222153407_471.png diff --git a/面试/面试/img/20161222153407_691.png b/面试(收集)/img/20161222153407_691.png similarity index 100% rename from 面试/面试/img/20161222153407_691.png rename to 面试(收集)/img/20161222153407_691.png diff --git a/面试/面试/img/ReentrantLock加锁和解锁过程.jpg b/面试(收集)/img/ReentrantLock加锁和解锁过程.jpg similarity index 100% rename from 面试/面试/img/ReentrantLock加锁和解锁过程.jpg rename to 面试(收集)/img/ReentrantLock加锁和解锁过程.jpg diff --git a/面试/面试/img/image-20200802001502483.png b/面试(收集)/img/image-20200802001502483.png similarity index 100% rename from 面试/面试/img/image-20200802001502483.png rename to 面试(收集)/img/image-20200802001502483.png diff --git a/面试/面试/img/image-20200802001515438.png b/面试(收集)/img/image-20200802001515438.png similarity index 100% rename from 面试/面试/img/image-20200802001515438.png rename to 面试(收集)/img/image-20200802001515438.png diff --git a/面试/面试/img/image-20200802001533126.png b/面试(收集)/img/image-20200802001533126.png similarity index 100% rename from 面试/面试/img/image-20200802001533126.png rename to 面试(收集)/img/image-20200802001533126.png diff --git a/面试/面试/img/image-20200802123228945.png b/面试(收集)/img/image-20200802123228945.png similarity index 100% rename from 面试/面试/img/image-20200802123228945.png rename to 面试(收集)/img/image-20200802123228945.png diff --git a/面试/面试/img/image-20200802123241536.png b/面试(收集)/img/image-20200802123241536.png similarity index 100% rename from 面试/面试/img/image-20200802123241536.png rename to 面试(收集)/img/image-20200802123241536.png diff --git a/面试/面试/img/image-20200802123304797.png b/面试(收集)/img/image-20200802123304797.png similarity index 100% rename from 面试/面试/img/image-20200802123304797.png rename to 面试(收集)/img/image-20200802123304797.png diff --git a/面试/面试/img/image-20200802123315514.png b/面试(收集)/img/image-20200802123315514.png similarity index 100% rename from 面试/面试/img/image-20200802123315514.png rename to 面试(收集)/img/image-20200802123315514.png diff --git a/面试/面试/img/image-20200802124108240.png b/面试(收集)/img/image-20200802124108240.png similarity index 100% rename from 面试/面试/img/image-20200802124108240.png rename to 面试(收集)/img/image-20200802124108240.png diff --git a/面试/面试/img/image-20200802124119931.png b/面试(收集)/img/image-20200802124119931.png similarity index 100% rename from 面试/面试/img/image-20200802124119931.png rename to 面试(收集)/img/image-20200802124119931.png diff --git a/面试/面试/img/image-20200802214415076.png b/面试(收集)/img/image-20200802214415076.png similarity index 100% rename from 面试/面试/img/image-20200802214415076.png rename to 面试(收集)/img/image-20200802214415076.png diff --git a/面试/面试/img/image-20200802215543670.png b/面试(收集)/img/image-20200802215543670.png similarity index 100% rename from 面试/面试/img/image-20200802215543670.png rename to 面试(收集)/img/image-20200802215543670.png diff --git a/面试/面试/img/image-20200802222734870.png b/面试(收集)/img/image-20200802222734870.png similarity index 100% rename from 面试/面试/img/image-20200802222734870.png rename to 面试(收集)/img/image-20200802222734870.png diff --git a/面试/面试/img/image-20200825231704058.png b/面试(收集)/img/image-20200825231704058.png similarity index 100% rename from 面试/面试/img/image-20200825231704058.png rename to 面试(收集)/img/image-20200825231704058.png diff --git a/面试/面试/img/image-20210824164717055.png b/面试(收集)/img/image-20210824164717055.png similarity index 100% rename from 面试/面试/img/image-20210824164717055.png rename to 面试(收集)/img/image-20210824164717055.png diff --git a/面试/面试/img/image-20210824164853535.png b/面试(收集)/img/image-20210824164853535.png similarity index 100% rename from 面试/面试/img/image-20210824164853535.png rename to 面试(收集)/img/image-20210824164853535.png diff --git a/面试/面试/img/image-20210831093204388.png b/面试(收集)/img/image-20210831093204388.png similarity index 100% rename from 面试/面试/img/image-20210831093204388.png rename to 面试(收集)/img/image-20210831093204388.png diff --git a/面试/面试/img/image-20220204222010008.png b/面试(收集)/img/image-20220204222010008.png similarity index 100% rename from 面试/面试/img/image-20220204222010008.png rename to 面试(收集)/img/image-20220204222010008.png diff --git a/面试/面试/img/image-20220205092454233.png b/面试(收集)/img/image-20220205092454233.png similarity index 100% rename from 面试/面试/img/image-20220205092454233.png rename to 面试(收集)/img/image-20220205092454233.png diff --git a/面试/面试/img/image-20220205094004602.png b/面试(收集)/img/image-20220205094004602.png similarity index 100% rename from 面试/面试/img/image-20220205094004602.png rename to 面试(收集)/img/image-20220205094004602.png diff --git a/面试/面试/img/image-20220205094926130.png b/面试(收集)/img/image-20220205094926130.png similarity index 100% rename from 面试/面试/img/image-20220205094926130.png rename to 面试(收集)/img/image-20220205094926130.png diff --git a/面试/面试/img/image-20220819132839511.png b/面试(收集)/img/image-20220819132839511.png similarity index 100% rename from 面试/面试/img/image-20220819132839511.png rename to 面试(收集)/img/image-20220819132839511.png diff --git a/面试/面试/img/image-20220819133305480.png b/面试(收集)/img/image-20220819133305480.png similarity index 100% rename from 面试/面试/img/image-20220819133305480.png rename to 面试(收集)/img/image-20220819133305480.png diff --git a/面试/面试/img/image-20220820104903422.png b/面试(收集)/img/image-20220820104903422.png similarity index 100% rename from 面试/面试/img/image-20220820104903422.png rename to 面试(收集)/img/image-20220820104903422.png diff --git a/面试/面试/img/image-20220820104950846.png b/面试(收集)/img/image-20220820104950846.png similarity index 100% rename from 面试/面试/img/image-20220820104950846.png rename to 面试(收集)/img/image-20220820104950846.png diff --git a/面试/面试/img/image-20220821003816845.png b/面试(收集)/img/image-20220821003816845.png similarity index 100% rename from 面试/面试/img/image-20220821003816845.png rename to 面试(收集)/img/image-20220821003816845.png diff --git a/面试/面试/img/image-20220826235336379.png b/面试(收集)/img/image-20220826235336379.png similarity index 100% rename from 面试/面试/img/image-20220826235336379.png rename to 面试(收集)/img/image-20220826235336379.png diff --git a/面试/面试/img/image-20220831195353786.png b/面试(收集)/img/image-20220831195353786.png similarity index 100% rename from 面试/面试/img/image-20220831195353786.png rename to 面试(收集)/img/image-20220831195353786.png diff --git a/面试/面试/img/image-20220901110122451.png b/面试(收集)/img/image-20220901110122451.png similarity index 100% rename from 面试/面试/img/image-20220901110122451.png rename to 面试(收集)/img/image-20220901110122451.png diff --git a/面试/面试/img/image-20220901112811374.png b/面试(收集)/img/image-20220901112811374.png similarity index 100% rename from 面试/面试/img/image-20220901112811374.png rename to 面试(收集)/img/image-20220901112811374.png diff --git a/面试/面试/img/image-20220901121814290.png b/面试(收集)/img/image-20220901121814290.png similarity index 100% rename from 面试/面试/img/image-20220901121814290.png rename to 面试(收集)/img/image-20220901121814290.png diff --git a/面试/面试/img/image-20220901121822268.png b/面试(收集)/img/image-20220901121822268.png similarity index 100% rename from 面试/面试/img/image-20220901121822268.png rename to 面试(收集)/img/image-20220901121822268.png diff --git a/面试/面试/img/image-20220901123003719.png b/面试(收集)/img/image-20220901123003719.png similarity index 100% rename from 面试/面试/img/image-20220901123003719.png rename to 面试(收集)/img/image-20220901123003719.png diff --git a/面试/面试/img/image-20220901123045003.png b/面试(收集)/img/image-20220901123045003.png similarity index 100% rename from 面试/面试/img/image-20220901123045003.png rename to 面试(收集)/img/image-20220901123045003.png diff --git a/面试/面试/img/image-20220901123201248.png b/面试(收集)/img/image-20220901123201248.png similarity index 100% rename from 面试/面试/img/image-20220901123201248.png rename to 面试(收集)/img/image-20220901123201248.png diff --git a/面试/面试/img/image-20220901140857623.png b/面试(收集)/img/image-20220901140857623.png similarity index 100% rename from 面试/面试/img/image-20220901140857623.png rename to 面试(收集)/img/image-20220901140857623.png diff --git a/面试/面试/img/image-20220901183856096.png b/面试(收集)/img/image-20220901183856096.png similarity index 100% rename from 面试/面试/img/image-20220901183856096.png rename to 面试(收集)/img/image-20220901183856096.png diff --git a/面试/面试/img/image-20220901184120726.png b/面试(收集)/img/image-20220901184120726.png similarity index 100% rename from 面试/面试/img/image-20220901184120726.png rename to 面试(收集)/img/image-20220901184120726.png diff --git a/面试/面试/img/image-20220902135259000.png b/面试(收集)/img/image-20220902135259000.png similarity index 100% rename from 面试/面试/img/image-20220902135259000.png rename to 面试(收集)/img/image-20220902135259000.png diff --git a/面试/面试/img/image-20220902171032898.png b/面试(收集)/img/image-20220902171032898.png similarity index 100% rename from 面试/面试/img/image-20220902171032898.png rename to 面试(收集)/img/image-20220902171032898.png diff --git a/面试/面试/img/image-20220902171426738.png b/面试(收集)/img/image-20220902171426738.png similarity index 100% rename from 面试/面试/img/image-20220902171426738.png rename to 面试(收集)/img/image-20220902171426738.png diff --git a/面试/面试/img/image-20220902172229567.png b/面试(收集)/img/image-20220902172229567.png similarity index 100% rename from 面试/面试/img/image-20220902172229567.png rename to 面试(收集)/img/image-20220902172229567.png diff --git a/面试/面试/img/image-20220902222630304.png b/面试(收集)/img/image-20220902222630304.png similarity index 100% rename from 面试/面试/img/image-20220902222630304.png rename to 面试(收集)/img/image-20220902222630304.png diff --git a/面试/面试/img/image-20220902222715863.png b/面试(收集)/img/image-20220902222715863.png similarity index 100% rename from 面试/面试/img/image-20220902222715863.png rename to 面试(收集)/img/image-20220902222715863.png diff --git a/面试/面试/img/image-20220902223846871.png b/面试(收集)/img/image-20220902223846871.png similarity index 100% rename from 面试/面试/img/image-20220902223846871.png rename to 面试(收集)/img/image-20220902223846871.png diff --git a/面试/面试/img/image-20220902224028438.png b/面试(收集)/img/image-20220902224028438.png similarity index 100% rename from 面试/面试/img/image-20220902224028438.png rename to 面试(收集)/img/image-20220902224028438.png diff --git a/面试/面试/img/image-20220903144547378.png b/面试(收集)/img/image-20220903144547378.png similarity index 100% rename from 面试/面试/img/image-20220903144547378.png rename to 面试(收集)/img/image-20220903144547378.png diff --git a/面试/面试/img/image-20220903144622508.png b/面试(收集)/img/image-20220903144622508.png similarity index 100% rename from 面试/面试/img/image-20220903144622508.png rename to 面试(收集)/img/image-20220903144622508.png diff --git a/面试/面试/img/image-20220903144652067.png b/面试(收集)/img/image-20220903144652067.png similarity index 100% rename from 面试/面试/img/image-20220903144652067.png rename to 面试(收集)/img/image-20220903144652067.png diff --git a/面试/面试/img/image-20220903215611606.png b/面试(收集)/img/image-20220903215611606.png similarity index 100% rename from 面试/面试/img/image-20220903215611606.png rename to 面试(收集)/img/image-20220903215611606.png diff --git a/面试/面试/img/image-20220903222719878.png b/面试(收集)/img/image-20220903222719878.png similarity index 100% rename from 面试/面试/img/image-20220903222719878.png rename to 面试(收集)/img/image-20220903222719878.png diff --git a/面试/面试/img/image-20220903233627146.png b/面试(收集)/img/image-20220903233627146.png similarity index 100% rename from 面试/面试/img/image-20220903233627146.png rename to 面试(收集)/img/image-20220903233627146.png diff --git a/面试/面试/img/image-20220904003215993.png b/面试(收集)/img/image-20220904003215993.png similarity index 100% rename from 面试/面试/img/image-20220904003215993.png rename to 面试(收集)/img/image-20220904003215993.png diff --git a/面试/面试/img/image-20220904003730785.png b/面试(收集)/img/image-20220904003730785.png similarity index 100% rename from 面试/面试/img/image-20220904003730785.png rename to 面试(收集)/img/image-20220904003730785.png diff --git a/面试/面试/img/image-20220904004306359.png b/面试(收集)/img/image-20220904004306359.png similarity index 100% rename from 面试/面试/img/image-20220904004306359.png rename to 面试(收集)/img/image-20220904004306359.png diff --git a/面试/面试/img/image-20220904005802025.png b/面试(收集)/img/image-20220904005802025.png similarity index 100% rename from 面试/面试/img/image-20220904005802025.png rename to 面试(收集)/img/image-20220904005802025.png diff --git a/面试/面试/img/image-20220904010012624.png b/面试(收集)/img/image-20220904010012624.png similarity index 100% rename from 面试/面试/img/image-20220904010012624.png rename to 面试(收集)/img/image-20220904010012624.png diff --git a/面试/面试/img/image-20220904010201496.png b/面试(收集)/img/image-20220904010201496.png similarity index 100% rename from 面试/面试/img/image-20220904010201496.png rename to 面试(收集)/img/image-20220904010201496.png diff --git a/面试/面试/img/image-20220904010634153.png b/面试(收集)/img/image-20220904010634153.png similarity index 100% rename from 面试/面试/img/image-20220904010634153.png rename to 面试(收集)/img/image-20220904010634153.png diff --git a/面试/面试/img/image-20220904104739581.png b/面试(收集)/img/image-20220904104739581.png similarity index 100% rename from 面试/面试/img/image-20220904104739581.png rename to 面试(收集)/img/image-20220904104739581.png diff --git a/面试/面试/img/image-20220904110804287.png b/面试(收集)/img/image-20220904110804287.png similarity index 100% rename from 面试/面试/img/image-20220904110804287.png rename to 面试(收集)/img/image-20220904110804287.png diff --git a/面试/面试/img/image-20220904111059602.png b/面试(收集)/img/image-20220904111059602.png similarity index 100% rename from 面试/面试/img/image-20220904111059602.png rename to 面试(收集)/img/image-20220904111059602.png diff --git a/面试/面试/img/image-20220904114511854.png b/面试(收集)/img/image-20220904114511854.png similarity index 100% rename from 面试/面试/img/image-20220904114511854.png rename to 面试(收集)/img/image-20220904114511854.png diff --git a/面试/面试/img/image-20220904115157363.png b/面试(收集)/img/image-20220904115157363.png similarity index 100% rename from 面试/面试/img/image-20220904115157363.png rename to 面试(收集)/img/image-20220904115157363.png diff --git a/面试/面试/img/image-20220904115936095.png b/面试(收集)/img/image-20220904115936095.png similarity index 100% rename from 面试/面试/img/image-20220904115936095.png rename to 面试(收集)/img/image-20220904115936095.png diff --git a/面试/面试/img/image-20220904120057211.png b/面试(收集)/img/image-20220904120057211.png similarity index 100% rename from 面试/面试/img/image-20220904120057211.png rename to 面试(收集)/img/image-20220904120057211.png diff --git a/面试/面试/img/image-20220904120256011.png b/面试(收集)/img/image-20220904120256011.png similarity index 100% rename from 面试/面试/img/image-20220904120256011.png rename to 面试(收集)/img/image-20220904120256011.png diff --git a/面试/面试/img/image-20220904120356174.png b/面试(收集)/img/image-20220904120356174.png similarity index 100% rename from 面试/面试/img/image-20220904120356174.png rename to 面试(收集)/img/image-20220904120356174.png diff --git a/面试/面试/img/image-20220904132000870.png b/面试(收集)/img/image-20220904132000870.png similarity index 100% rename from 面试/面试/img/image-20220904132000870.png rename to 面试(收集)/img/image-20220904132000870.png diff --git a/面试/面试/img/image-20220904132011289.png b/面试(收集)/img/image-20220904132011289.png similarity index 100% rename from 面试/面试/img/image-20220904132011289.png rename to 面试(收集)/img/image-20220904132011289.png diff --git a/面试/面试/img/image-20220904132134095.png b/面试(收集)/img/image-20220904132134095.png similarity index 100% rename from 面试/面试/img/image-20220904132134095.png rename to 面试(收集)/img/image-20220904132134095.png diff --git a/面试/面试/img/image-20220904132346495.png b/面试(收集)/img/image-20220904132346495.png similarity index 100% rename from 面试/面试/img/image-20220904132346495.png rename to 面试(收集)/img/image-20220904132346495.png diff --git a/面试/面试/img/image-20220904132925812.png b/面试(收集)/img/image-20220904132925812.png similarity index 100% rename from 面试/面试/img/image-20220904132925812.png rename to 面试(收集)/img/image-20220904132925812.png diff --git a/面试/面试/img/image-20220904133722905.png b/面试(收集)/img/image-20220904133722905.png similarity index 100% rename from 面试/面试/img/image-20220904133722905.png rename to 面试(收集)/img/image-20220904133722905.png diff --git a/面试/面试/img/image-20220904151948778.png b/面试(收集)/img/image-20220904151948778.png similarity index 100% rename from 面试/面试/img/image-20220904151948778.png rename to 面试(收集)/img/image-20220904151948778.png diff --git a/面试/面试/img/image-20220904161818255.png b/面试(收集)/img/image-20220904161818255.png similarity index 100% rename from 面试/面试/img/image-20220904161818255.png rename to 面试(收集)/img/image-20220904161818255.png diff --git a/面试/面试/img/image-20220904162117022.png b/面试(收集)/img/image-20220904162117022.png similarity index 100% rename from 面试/面试/img/image-20220904162117022.png rename to 面试(收集)/img/image-20220904162117022.png diff --git a/面试/面试/img/image-20220904162654928.png b/面试(收集)/img/image-20220904162654928.png similarity index 100% rename from 面试/面试/img/image-20220904162654928.png rename to 面试(收集)/img/image-20220904162654928.png diff --git a/面试/面试/img/image-20220904162941977.png b/面试(收集)/img/image-20220904162941977.png similarity index 100% rename from 面试/面试/img/image-20220904162941977.png rename to 面试(收集)/img/image-20220904162941977.png diff --git a/面试/面试/img/image-20220907174130993.png b/面试(收集)/img/image-20220907174130993.png similarity index 100% rename from 面试/面试/img/image-20220907174130993.png rename to 面试(收集)/img/image-20220907174130993.png diff --git a/面试/面试/img/image-20220907174151925.png b/面试(收集)/img/image-20220907174151925.png similarity index 100% rename from 面试/面试/img/image-20220907174151925.png rename to 面试(收集)/img/image-20220907174151925.png diff --git a/面试/面试/img/image-20220907174308262.png b/面试(收集)/img/image-20220907174308262.png similarity index 100% rename from 面试/面试/img/image-20220907174308262.png rename to 面试(收集)/img/image-20220907174308262.png diff --git a/面试/面试/img/image-20220913110351052.png b/面试(收集)/img/image-20220913110351052.png similarity index 100% rename from 面试/面试/img/image-20220913110351052.png rename to 面试(收集)/img/image-20220913110351052.png diff --git a/面试/面试/img/image-20220913110915155.png b/面试(收集)/img/image-20220913110915155.png similarity index 100% rename from 面试/面试/img/image-20220913110915155.png rename to 面试(收集)/img/image-20220913110915155.png diff --git a/面试/面试/img/image-20220913115948157.png b/面试(收集)/img/image-20220913115948157.png similarity index 100% rename from 面试/面试/img/image-20220913115948157.png rename to 面试(收集)/img/image-20220913115948157.png diff --git a/面试/面试/img/image-20220913123809949.png b/面试(收集)/img/image-20220913123809949.png similarity index 100% rename from 面试/面试/img/image-20220913123809949.png rename to 面试(收集)/img/image-20220913123809949.png diff --git a/面试/面试/img/image-20220913124542154.png b/面试(收集)/img/image-20220913124542154.png similarity index 100% rename from 面试/面试/img/image-20220913124542154.png rename to 面试(收集)/img/image-20220913124542154.png diff --git a/面试/面试/img/image-20220913125116591.png b/面试(收集)/img/image-20220913125116591.png similarity index 100% rename from 面试/面试/img/image-20220913125116591.png rename to 面试(收集)/img/image-20220913125116591.png diff --git a/面试/面试/img/image-20220913125131383.png b/面试(收集)/img/image-20220913125131383.png similarity index 100% rename from 面试/面试/img/image-20220913125131383.png rename to 面试(收集)/img/image-20220913125131383.png diff --git a/面试/面试/img/image-20220913125209804.png b/面试(收集)/img/image-20220913125209804.png similarity index 100% rename from 面试/面试/img/image-20220913125209804.png rename to 面试(收集)/img/image-20220913125209804.png diff --git a/面试/面试/img/image-20221007101811885.png b/面试(收集)/img/image-20221007101811885.png similarity index 100% rename from 面试/面试/img/image-20221007101811885.png rename to 面试(收集)/img/image-20221007101811885.png diff --git a/面试/面试/img/image-20221007101831281.png b/面试(收集)/img/image-20221007101831281.png similarity index 100% rename from 面试/面试/img/image-20221007101831281.png rename to 面试(收集)/img/image-20221007101831281.png diff --git a/面试/面试/img/image-20221007110404375.png b/面试(收集)/img/image-20221007110404375.png similarity index 100% rename from 面试/面试/img/image-20221007110404375.png rename to 面试(收集)/img/image-20221007110404375.png diff --git a/面试/面试/img/image-20221007151910345.png b/面试(收集)/img/image-20221007151910345.png similarity index 100% rename from 面试/面试/img/image-20221007151910345.png rename to 面试(收集)/img/image-20221007151910345.png diff --git a/面试/面试/img/image-20221007151919542.png b/面试(收集)/img/image-20221007151919542.png similarity index 100% rename from 面试/面试/img/image-20221007151919542.png rename to 面试(收集)/img/image-20221007151919542.png diff --git a/面试/面试/img/image-20221026105350827.png b/面试(收集)/img/image-20221026105350827.png similarity index 100% rename from 面试/面试/img/image-20221026105350827.png rename to 面试(收集)/img/image-20221026105350827.png diff --git a/面试/面试/img/image-20221026105442158.png b/面试(收集)/img/image-20221026105442158.png similarity index 100% rename from 面试/面试/img/image-20221026105442158.png rename to 面试(收集)/img/image-20221026105442158.png diff --git a/面试/面试/img/image-20221026105607248.png b/面试(收集)/img/image-20221026105607248.png similarity index 100% rename from 面试/面试/img/image-20221026105607248.png rename to 面试(收集)/img/image-20221026105607248.png diff --git a/面试/面试/img/image-20221026105641323.png b/面试(收集)/img/image-20221026105641323.png similarity index 100% rename from 面试/面试/img/image-20221026105641323.png rename to 面试(收集)/img/image-20221026105641323.png diff --git a/面试/面试/img/image-20221026110310947.png b/面试(收集)/img/image-20221026110310947.png similarity index 100% rename from 面试/面试/img/image-20221026110310947.png rename to 面试(收集)/img/image-20221026110310947.png diff --git a/面试/面试/img/image-20221026110452264.png b/面试(收集)/img/image-20221026110452264.png similarity index 100% rename from 面试/面试/img/image-20221026110452264.png rename to 面试(收集)/img/image-20221026110452264.png diff --git a/面试/面试/img/image-20221026111154135.png b/面试(收集)/img/image-20221026111154135.png similarity index 100% rename from 面试/面试/img/image-20221026111154135.png rename to 面试(收集)/img/image-20221026111154135.png diff --git a/面试/面试/img/image-20221026111227136.png b/面试(收集)/img/image-20221026111227136.png similarity index 100% rename from 面试/面试/img/image-20221026111227136.png rename to 面试(收集)/img/image-20221026111227136.png diff --git a/面试/面试/img/image-20221026111246089.png b/面试(收集)/img/image-20221026111246089.png similarity index 100% rename from 面试/面试/img/image-20221026111246089.png rename to 面试(收集)/img/image-20221026111246089.png diff --git a/面试/面试/img/image-20221026111313057.png b/面试(收集)/img/image-20221026111313057.png similarity index 100% rename from 面试/面试/img/image-20221026111313057.png rename to 面试(收集)/img/image-20221026111313057.png diff --git a/面试/面试/img/image-20221026111429301.png b/面试(收集)/img/image-20221026111429301.png similarity index 100% rename from 面试/面试/img/image-20221026111429301.png rename to 面试(收集)/img/image-20221026111429301.png diff --git a/面试/面试/img/image-20221026111506307.png b/面试(收集)/img/image-20221026111506307.png similarity index 100% rename from 面试/面试/img/image-20221026111506307.png rename to 面试(收集)/img/image-20221026111506307.png diff --git a/面试/面试/img/image-20230427162524322.png b/面试(收集)/img/image-20230427162524322.png similarity index 100% rename from 面试/面试/img/image-20230427162524322.png rename to 面试(收集)/img/image-20230427162524322.png diff --git a/面试/面试/img/image-20230427173120668.png b/面试(收集)/img/image-20230427173120668.png similarity index 100% rename from 面试/面试/img/image-20230427173120668.png rename to 面试(收集)/img/image-20230427173120668.png diff --git a/面试/面试/img/image-20230427173742389.png b/面试(收集)/img/image-20230427173742389.png similarity index 100% rename from 面试/面试/img/image-20230427173742389.png rename to 面试(收集)/img/image-20230427173742389.png diff --git a/面试/面试/img/image-20230427173922705.png b/面试(收集)/img/image-20230427173922705.png similarity index 100% rename from 面试/面试/img/image-20230427173922705.png rename to 面试(收集)/img/image-20230427173922705.png diff --git a/面试/面试/img/image-20230427173937663.png b/面试(收集)/img/image-20230427173937663.png similarity index 100% rename from 面试/面试/img/image-20230427173937663.png rename to 面试(收集)/img/image-20230427173937663.png diff --git a/面试/面试/img/image-20230427174832858.png b/面试(收集)/img/image-20230427174832858.png similarity index 100% rename from 面试/面试/img/image-20230427174832858.png rename to 面试(收集)/img/image-20230427174832858.png diff --git a/面试/面试/img/image-20230427175545402.png b/面试(收集)/img/image-20230427175545402.png similarity index 100% rename from 面试/面试/img/image-20230427175545402.png rename to 面试(收集)/img/image-20230427175545402.png diff --git a/面试/面试/img/image-20230427175633253.png b/面试(收集)/img/image-20230427175633253.png similarity index 100% rename from 面试/面试/img/image-20230427175633253.png rename to 面试(收集)/img/image-20230427175633253.png diff --git a/面试/面试/img/image-20230427175849493.png b/面试(收集)/img/image-20230427175849493.png similarity index 100% rename from 面试/面试/img/image-20230427175849493.png rename to 面试(收集)/img/image-20230427175849493.png diff --git a/面试/面试/img/image-20230427180056509.png b/面试(收集)/img/image-20230427180056509.png similarity index 100% rename from 面试/面试/img/image-20230427180056509.png rename to 面试(收集)/img/image-20230427180056509.png diff --git a/面试/面试/img/image-20230427192118259.png b/面试(收集)/img/image-20230427192118259.png similarity index 100% rename from 面试/面试/img/image-20230427192118259.png rename to 面试(收集)/img/image-20230427192118259.png diff --git a/面试/面试/img/image-20230427192154014.png b/面试(收集)/img/image-20230427192154014.png similarity index 100% rename from 面试/面试/img/image-20230427192154014.png rename to 面试(收集)/img/image-20230427192154014.png diff --git a/面试/面试/img/image-20230427192200918.png b/面试(收集)/img/image-20230427192200918.png similarity index 100% rename from 面试/面试/img/image-20230427192200918.png rename to 面试(收集)/img/image-20230427192200918.png diff --git a/面试/面试/img/image-20230427192620292.png b/面试(收集)/img/image-20230427192620292.png similarity index 100% rename from 面试/面试/img/image-20230427192620292.png rename to 面试(收集)/img/image-20230427192620292.png diff --git a/面试/面试/img/image-20230427192644244.png b/面试(收集)/img/image-20230427192644244.png similarity index 100% rename from 面试/面试/img/image-20230427192644244.png rename to 面试(收集)/img/image-20230427192644244.png diff --git a/面试/面试/img/image-20230428185505677.png b/面试(收集)/img/image-20230428185505677.png similarity index 100% rename from 面试/面试/img/image-20230428185505677.png rename to 面试(收集)/img/image-20230428185505677.png diff --git a/面试/面试/img/image-20230428185600918.png b/面试(收集)/img/image-20230428185600918.png similarity index 100% rename from 面试/面试/img/image-20230428185600918.png rename to 面试(收集)/img/image-20230428185600918.png diff --git a/面试/面试/img/image-20230428185657791.png b/面试(收集)/img/image-20230428185657791.png similarity index 100% rename from 面试/面试/img/image-20230428185657791.png rename to 面试(收集)/img/image-20230428185657791.png diff --git a/面试/面试/img/image-20230428185922776.png b/面试(收集)/img/image-20230428185922776.png similarity index 100% rename from 面试/面试/img/image-20230428185922776.png rename to 面试(收集)/img/image-20230428185922776.png diff --git a/面试/面试/img/image-20230428185945929.png b/面试(收集)/img/image-20230428185945929.png similarity index 100% rename from 面试/面试/img/image-20230428185945929.png rename to 面试(收集)/img/image-20230428185945929.png diff --git a/面试/面试/img/image-20230428190130901.png b/面试(收集)/img/image-20230428190130901.png similarity index 100% rename from 面试/面试/img/image-20230428190130901.png rename to 面试(收集)/img/image-20230428190130901.png diff --git a/面试/面试/img/image-20230428190210915.png b/面试(收集)/img/image-20230428190210915.png similarity index 100% rename from 面试/面试/img/image-20230428190210915.png rename to 面试(收集)/img/image-20230428190210915.png diff --git a/面试/面试/img/image-20230428190324752.png b/面试(收集)/img/image-20230428190324752.png similarity index 100% rename from 面试/面试/img/image-20230428190324752.png rename to 面试(收集)/img/image-20230428190324752.png diff --git a/面试/面试/img/image-20230428190353286.png b/面试(收集)/img/image-20230428190353286.png similarity index 100% rename from 面试/面试/img/image-20230428190353286.png rename to 面试(收集)/img/image-20230428190353286.png diff --git a/面试/面试/img/image-20230428190450517.png b/面试(收集)/img/image-20230428190450517.png similarity index 100% rename from 面试/面试/img/image-20230428190450517.png rename to 面试(收集)/img/image-20230428190450517.png diff --git a/面试/面试/img/image-20230428194641694.png b/面试(收集)/img/image-20230428194641694.png similarity index 100% rename from 面试/面试/img/image-20230428194641694.png rename to 面试(收集)/img/image-20230428194641694.png diff --git a/面试/面试/img/image-20230428194715016.png b/面试(收集)/img/image-20230428194715016.png similarity index 100% rename from 面试/面试/img/image-20230428194715016.png rename to 面试(收集)/img/image-20230428194715016.png diff --git a/面试/面试/img/image-20230428194831426.png b/面试(收集)/img/image-20230428194831426.png similarity index 100% rename from 面试/面试/img/image-20230428194831426.png rename to 面试(收集)/img/image-20230428194831426.png diff --git a/面试/面试/img/image-20230428194904383.png b/面试(收集)/img/image-20230428194904383.png similarity index 100% rename from 面试/面试/img/image-20230428194904383.png rename to 面试(收集)/img/image-20230428194904383.png diff --git a/面试/面试/img/image-20230428194931132.png b/面试(收集)/img/image-20230428194931132.png similarity index 100% rename from 面试/面试/img/image-20230428194931132.png rename to 面试(收集)/img/image-20230428194931132.png diff --git a/面试/面试/img/image-20230428195206422.png b/面试(收集)/img/image-20230428195206422.png similarity index 100% rename from 面试/面试/img/image-20230428195206422.png rename to 面试(收集)/img/image-20230428195206422.png diff --git a/面试/面试/img/image-20230428195341917.png b/面试(收集)/img/image-20230428195341917.png similarity index 100% rename from 面试/面试/img/image-20230428195341917.png rename to 面试(收集)/img/image-20230428195341917.png diff --git a/面试/面试/img/image-20230428195449799.png b/面试(收集)/img/image-20230428195449799.png similarity index 100% rename from 面试/面试/img/image-20230428195449799.png rename to 面试(收集)/img/image-20230428195449799.png diff --git a/面试/面试/img/image-20230428195832724.png b/面试(收集)/img/image-20230428195832724.png similarity index 100% rename from 面试/面试/img/image-20230428195832724.png rename to 面试(收集)/img/image-20230428195832724.png diff --git a/面试/面试/img/image-20230428200919454.png b/面试(收集)/img/image-20230428200919454.png similarity index 100% rename from 面试/面试/img/image-20230428200919454.png rename to 面试(收集)/img/image-20230428200919454.png diff --git a/面试/面试/img/image-20230428201000814.png b/面试(收集)/img/image-20230428201000814.png similarity index 100% rename from 面试/面试/img/image-20230428201000814.png rename to 面试(收集)/img/image-20230428201000814.png diff --git a/面试/面试/img/image-20230428201321607.png b/面试(收集)/img/image-20230428201321607.png similarity index 100% rename from 面试/面试/img/image-20230428201321607.png rename to 面试(收集)/img/image-20230428201321607.png diff --git a/面试/面试/img/image-20230428203219225.png b/面试(收集)/img/image-20230428203219225.png similarity index 100% rename from 面试/面试/img/image-20230428203219225.png rename to 面试(收集)/img/image-20230428203219225.png diff --git a/面试/面试/img/image-20230428203437910.png b/面试(收集)/img/image-20230428203437910.png similarity index 100% rename from 面试/面试/img/image-20230428203437910.png rename to 面试(收集)/img/image-20230428203437910.png diff --git a/面试/面试/img/image-20230428203711269.png b/面试(收集)/img/image-20230428203711269.png similarity index 100% rename from 面试/面试/img/image-20230428203711269.png rename to 面试(收集)/img/image-20230428203711269.png diff --git a/面试/面试/img/image-20230428203858903.png b/面试(收集)/img/image-20230428203858903.png similarity index 100% rename from 面试/面试/img/image-20230428203858903.png rename to 面试(收集)/img/image-20230428203858903.png diff --git a/面试/面试/img/image-20230428203924816.png b/面试(收集)/img/image-20230428203924816.png similarity index 100% rename from 面试/面试/img/image-20230428203924816.png rename to 面试(收集)/img/image-20230428203924816.png diff --git a/面试/面试/img/image-20230428204902016.png b/面试(收集)/img/image-20230428204902016.png similarity index 100% rename from 面试/面试/img/image-20230428204902016.png rename to 面试(收集)/img/image-20230428204902016.png diff --git a/面试/面试/img/image-20230428210404117.png b/面试(收集)/img/image-20230428210404117.png similarity index 100% rename from 面试/面试/img/image-20230428210404117.png rename to 面试(收集)/img/image-20230428210404117.png diff --git a/面试/面试/img/image-20230428210424304.png b/面试(收集)/img/image-20230428210424304.png similarity index 100% rename from 面试/面试/img/image-20230428210424304.png rename to 面试(收集)/img/image-20230428210424304.png diff --git a/面试/面试/img/image-20230428210450744.png b/面试(收集)/img/image-20230428210450744.png similarity index 100% rename from 面试/面试/img/image-20230428210450744.png rename to 面试(收集)/img/image-20230428210450744.png diff --git a/面试/面试/img/image-20230428210624847.png b/面试(收集)/img/image-20230428210624847.png similarity index 100% rename from 面试/面试/img/image-20230428210624847.png rename to 面试(收集)/img/image-20230428210624847.png diff --git a/面试/面试/img/image-20230428210844694.png b/面试(收集)/img/image-20230428210844694.png similarity index 100% rename from 面试/面试/img/image-20230428210844694.png rename to 面试(收集)/img/image-20230428210844694.png diff --git a/面试/面试/img/image-20230428211031968.png b/面试(收集)/img/image-20230428211031968.png similarity index 100% rename from 面试/面试/img/image-20230428211031968.png rename to 面试(收集)/img/image-20230428211031968.png diff --git a/面试/面试/img/image-20230428211304013.png b/面试(收集)/img/image-20230428211304013.png similarity index 100% rename from 面试/面试/img/image-20230428211304013.png rename to 面试(收集)/img/image-20230428211304013.png diff --git a/面试/面试/img/image-20230428212501408.png b/面试(收集)/img/image-20230428212501408.png similarity index 100% rename from 面试/面试/img/image-20230428212501408.png rename to 面试(收集)/img/image-20230428212501408.png diff --git a/面试/面试/img/image-20230428212553393.png b/面试(收集)/img/image-20230428212553393.png similarity index 100% rename from 面试/面试/img/image-20230428212553393.png rename to 面试(收集)/img/image-20230428212553393.png diff --git a/面试/面试/img/image-20230428212601977.png b/面试(收集)/img/image-20230428212601977.png similarity index 100% rename from 面试/面试/img/image-20230428212601977.png rename to 面试(收集)/img/image-20230428212601977.png diff --git a/面试/面试/img/image-20230428212729580.png b/面试(收集)/img/image-20230428212729580.png similarity index 100% rename from 面试/面试/img/image-20230428212729580.png rename to 面试(收集)/img/image-20230428212729580.png diff --git a/面试/面试/img/image-20230428213115071.png b/面试(收集)/img/image-20230428213115071.png similarity index 100% rename from 面试/面试/img/image-20230428213115071.png rename to 面试(收集)/img/image-20230428213115071.png diff --git a/面试/面试/img/image-20230428213513669.png b/面试(收集)/img/image-20230428213513669.png similarity index 100% rename from 面试/面试/img/image-20230428213513669.png rename to 面试(收集)/img/image-20230428213513669.png diff --git a/面试/面试/img/image-20230428213533483.png b/面试(收集)/img/image-20230428213533483.png similarity index 100% rename from 面试/面试/img/image-20230428213533483.png rename to 面试(收集)/img/image-20230428213533483.png diff --git a/面试/面试/img/image-20230428214732877.png b/面试(收集)/img/image-20230428214732877.png similarity index 100% rename from 面试/面试/img/image-20230428214732877.png rename to 面试(收集)/img/image-20230428214732877.png diff --git a/面试/面试/img/image-20230428214806072.png b/面试(收集)/img/image-20230428214806072.png similarity index 100% rename from 面试/面试/img/image-20230428214806072.png rename to 面试(收集)/img/image-20230428214806072.png diff --git a/面试/面试/img/image-20230428214908652.png b/面试(收集)/img/image-20230428214908652.png similarity index 100% rename from 面试/面试/img/image-20230428214908652.png rename to 面试(收集)/img/image-20230428214908652.png diff --git a/面试/面试/img/image-20230428214937231.png b/面试(收集)/img/image-20230428214937231.png similarity index 100% rename from 面试/面试/img/image-20230428214937231.png rename to 面试(收集)/img/image-20230428214937231.png diff --git a/面试/面试/img/image-20230503203246348.png b/面试(收集)/img/image-20230503203246348.png similarity index 100% rename from 面试/面试/img/image-20230503203246348.png rename to 面试(收集)/img/image-20230503203246348.png diff --git a/面试/面试/img/image-20230503203330700.png b/面试(收集)/img/image-20230503203330700.png similarity index 100% rename from 面试/面试/img/image-20230503203330700.png rename to 面试(收集)/img/image-20230503203330700.png diff --git a/面试/面试/img/image-20230503203629212.png b/面试(收集)/img/image-20230503203629212.png similarity index 100% rename from 面试/面试/img/image-20230503203629212.png rename to 面试(收集)/img/image-20230503203629212.png diff --git a/面试/面试/img/image-20230504165342501.png b/面试(收集)/img/image-20230504165342501.png similarity index 100% rename from 面试/面试/img/image-20230504165342501.png rename to 面试(收集)/img/image-20230504165342501.png diff --git a/面试/面试/img/image-20230504165833809.png b/面试(收集)/img/image-20230504165833809.png similarity index 100% rename from 面试/面试/img/image-20230504165833809.png rename to 面试(收集)/img/image-20230504165833809.png diff --git a/面试/面试/img/image-20230504172253826.png b/面试(收集)/img/image-20230504172253826.png similarity index 100% rename from 面试/面试/img/image-20230504172253826.png rename to 面试(收集)/img/image-20230504172253826.png diff --git a/面试/面试/img/image-20230504172414210.png b/面试(收集)/img/image-20230504172414210.png similarity index 100% rename from 面试/面试/img/image-20230504172414210.png rename to 面试(收集)/img/image-20230504172414210.png diff --git a/面试/面试/img/image-20230504172541922.png b/面试(收集)/img/image-20230504172541922.png similarity index 100% rename from 面试/面试/img/image-20230504172541922.png rename to 面试(收集)/img/image-20230504172541922.png diff --git a/面试/面试/img/image-20230504172957271.png b/面试(收集)/img/image-20230504172957271.png similarity index 100% rename from 面试/面试/img/image-20230504172957271.png rename to 面试(收集)/img/image-20230504172957271.png diff --git a/面试/面试/img/image-20230504173414009.png b/面试(收集)/img/image-20230504173414009.png similarity index 100% rename from 面试/面试/img/image-20230504173414009.png rename to 面试(收集)/img/image-20230504173414009.png diff --git a/面试/面试/img/image-20230504173520412.png b/面试(收集)/img/image-20230504173520412.png similarity index 100% rename from 面试/面试/img/image-20230504173520412.png rename to 面试(收集)/img/image-20230504173520412.png diff --git a/面试/面试/img/image-20230504173611219.png b/面试(收集)/img/image-20230504173611219.png similarity index 100% rename from 面试/面试/img/image-20230504173611219.png rename to 面试(收集)/img/image-20230504173611219.png diff --git a/面试/面试/img/image-20230504173711041.png b/面试(收集)/img/image-20230504173711041.png similarity index 100% rename from 面试/面试/img/image-20230504173711041.png rename to 面试(收集)/img/image-20230504173711041.png diff --git a/面试/面试/img/image-20230504173922343.png b/面试(收集)/img/image-20230504173922343.png similarity index 100% rename from 面试/面试/img/image-20230504173922343.png rename to 面试(收集)/img/image-20230504173922343.png diff --git a/面试/面试/img/image-20230504173955680.png b/面试(收集)/img/image-20230504173955680.png similarity index 100% rename from 面试/面试/img/image-20230504173955680.png rename to 面试(收集)/img/image-20230504173955680.png diff --git a/面试/面试/img/image-20230504174045458.png b/面试(收集)/img/image-20230504174045458.png similarity index 100% rename from 面试/面试/img/image-20230504174045458.png rename to 面试(收集)/img/image-20230504174045458.png diff --git a/面试/面试/img/image-20230504174505031.png b/面试(收集)/img/image-20230504174505031.png similarity index 100% rename from 面试/面试/img/image-20230504174505031.png rename to 面试(收集)/img/image-20230504174505031.png diff --git a/面试/面试/img/image-20230504174525256.png b/面试(收集)/img/image-20230504174525256.png similarity index 100% rename from 面试/面试/img/image-20230504174525256.png rename to 面试(收集)/img/image-20230504174525256.png diff --git a/面试/面试/img/image-20230504174736226.png b/面试(收集)/img/image-20230504174736226.png similarity index 100% rename from 面试/面试/img/image-20230504174736226.png rename to 面试(收集)/img/image-20230504174736226.png diff --git a/面试/面试/img/image-20230504181638237.png b/面试(收集)/img/image-20230504181638237.png similarity index 100% rename from 面试/面试/img/image-20230504181638237.png rename to 面试(收集)/img/image-20230504181638237.png diff --git a/面试/面试/img/image-20230504181827330.png b/面试(收集)/img/image-20230504181827330.png similarity index 100% rename from 面试/面试/img/image-20230504181827330.png rename to 面试(收集)/img/image-20230504181827330.png diff --git a/面试/面试/img/image-20230504181947319.png b/面试(收集)/img/image-20230504181947319.png similarity index 100% rename from 面试/面试/img/image-20230504181947319.png rename to 面试(收集)/img/image-20230504181947319.png diff --git a/面试/面试/img/image-20230504182129820.png b/面试(收集)/img/image-20230504182129820.png similarity index 100% rename from 面试/面试/img/image-20230504182129820.png rename to 面试(收集)/img/image-20230504182129820.png diff --git a/面试/面试/img/image-20230504182447552.png b/面试(收集)/img/image-20230504182447552.png similarity index 100% rename from 面试/面试/img/image-20230504182447552.png rename to 面试(收集)/img/image-20230504182447552.png diff --git a/面试/面试/img/image-20230504182737931.png b/面试(收集)/img/image-20230504182737931.png similarity index 100% rename from 面试/面试/img/image-20230504182737931.png rename to 面试(收集)/img/image-20230504182737931.png diff --git a/面试/面试/img/image-20230504182838426.png b/面试(收集)/img/image-20230504182838426.png similarity index 100% rename from 面试/面试/img/image-20230504182838426.png rename to 面试(收集)/img/image-20230504182838426.png diff --git a/面试/面试/img/image-20230504182958703.png b/面试(收集)/img/image-20230504182958703.png similarity index 100% rename from 面试/面试/img/image-20230504182958703.png rename to 面试(收集)/img/image-20230504182958703.png diff --git a/面试/面试/img/image-20230505082441116.png b/面试(收集)/img/image-20230505082441116.png similarity index 100% rename from 面试/面试/img/image-20230505082441116.png rename to 面试(收集)/img/image-20230505082441116.png diff --git a/面试/面试/img/image-20230505082835588.png b/面试(收集)/img/image-20230505082835588.png similarity index 100% rename from 面试/面试/img/image-20230505082835588.png rename to 面试(收集)/img/image-20230505082835588.png diff --git a/面试/面试/img/image-20230505082923729.png b/面试(收集)/img/image-20230505082923729.png similarity index 100% rename from 面试/面试/img/image-20230505082923729.png rename to 面试(收集)/img/image-20230505082923729.png diff --git a/面试/面试/img/image-20230505083124159.png b/面试(收集)/img/image-20230505083124159.png similarity index 100% rename from 面试/面试/img/image-20230505083124159.png rename to 面试(收集)/img/image-20230505083124159.png diff --git a/面试/面试/img/image-20230505083159269.png b/面试(收集)/img/image-20230505083159269.png similarity index 100% rename from 面试/面试/img/image-20230505083159269.png rename to 面试(收集)/img/image-20230505083159269.png diff --git a/面试/面试/img/image-20230505083217904.png b/面试(收集)/img/image-20230505083217904.png similarity index 100% rename from 面试/面试/img/image-20230505083217904.png rename to 面试(收集)/img/image-20230505083217904.png diff --git a/面试/面试/img/image-20230505083840046.png b/面试(收集)/img/image-20230505083840046.png similarity index 100% rename from 面试/面试/img/image-20230505083840046.png rename to 面试(收集)/img/image-20230505083840046.png diff --git a/面试/面试/img/image-20230505084451193.png b/面试(收集)/img/image-20230505084451193.png similarity index 100% rename from 面试/面试/img/image-20230505084451193.png rename to 面试(收集)/img/image-20230505084451193.png diff --git a/面试/面试/img/image-20230505091736569.png b/面试(收集)/img/image-20230505091736569.png similarity index 100% rename from 面试/面试/img/image-20230505091736569.png rename to 面试(收集)/img/image-20230505091736569.png diff --git a/面试/面试/img/image-20230505091827720.png b/面试(收集)/img/image-20230505091827720.png similarity index 100% rename from 面试/面试/img/image-20230505091827720.png rename to 面试(收集)/img/image-20230505091827720.png diff --git a/面试/面试/img/image-20230505091833629.png b/面试(收集)/img/image-20230505091833629.png similarity index 100% rename from 面试/面试/img/image-20230505091833629.png rename to 面试(收集)/img/image-20230505091833629.png diff --git a/面试/面试/img/image-20230505092151244.png b/面试(收集)/img/image-20230505092151244.png similarity index 100% rename from 面试/面试/img/image-20230505092151244.png rename to 面试(收集)/img/image-20230505092151244.png diff --git a/面试/面试/img/image-20230505092340431.png b/面试(收集)/img/image-20230505092340431.png similarity index 100% rename from 面试/面试/img/image-20230505092340431.png rename to 面试(收集)/img/image-20230505092340431.png diff --git a/面试/面试/img/image-20230505092654811.png b/面试(收集)/img/image-20230505092654811.png similarity index 100% rename from 面试/面试/img/image-20230505092654811.png rename to 面试(收集)/img/image-20230505092654811.png diff --git a/面试/面试/img/image-20230505093055382.png b/面试(收集)/img/image-20230505093055382.png similarity index 100% rename from 面试/面试/img/image-20230505093055382.png rename to 面试(收集)/img/image-20230505093055382.png diff --git a/面试/面试/img/image-20230505093507265.png b/面试(收集)/img/image-20230505093507265.png similarity index 100% rename from 面试/面试/img/image-20230505093507265.png rename to 面试(收集)/img/image-20230505093507265.png diff --git a/面试/面试/img/image-20230505205200628.png b/面试(收集)/img/image-20230505205200628.png similarity index 100% rename from 面试/面试/img/image-20230505205200628.png rename to 面试(收集)/img/image-20230505205200628.png diff --git a/面试/面试/img/image-20230505210853493.png b/面试(收集)/img/image-20230505210853493.png similarity index 100% rename from 面试/面试/img/image-20230505210853493.png rename to 面试(收集)/img/image-20230505210853493.png diff --git a/面试/面试/img/image-20230505211002252.png b/面试(收集)/img/image-20230505211002252.png similarity index 100% rename from 面试/面试/img/image-20230505211002252.png rename to 面试(收集)/img/image-20230505211002252.png diff --git a/面试/面试/img/image-20230505211209336.png b/面试(收集)/img/image-20230505211209336.png similarity index 100% rename from 面试/面试/img/image-20230505211209336.png rename to 面试(收集)/img/image-20230505211209336.png diff --git a/面试/面试/img/image-20230505220514872.png b/面试(收集)/img/image-20230505220514872.png similarity index 100% rename from 面试/面试/img/image-20230505220514872.png rename to 面试(收集)/img/image-20230505220514872.png diff --git a/面试/面试/img/image-20230505220701835.png b/面试(收集)/img/image-20230505220701835.png similarity index 100% rename from 面试/面试/img/image-20230505220701835.png rename to 面试(收集)/img/image-20230505220701835.png diff --git a/面试/面试/img/image-20230505221424359.png b/面试(收集)/img/image-20230505221424359.png similarity index 100% rename from 面试/面试/img/image-20230505221424359.png rename to 面试(收集)/img/image-20230505221424359.png diff --git a/面试/面试/img/image-20230505221837189.png b/面试(收集)/img/image-20230505221837189.png similarity index 100% rename from 面试/面试/img/image-20230505221837189.png rename to 面试(收集)/img/image-20230505221837189.png diff --git a/面试/面试/img/image-20230505221959259.png b/面试(收集)/img/image-20230505221959259.png similarity index 100% rename from 面试/面试/img/image-20230505221959259.png rename to 面试(收集)/img/image-20230505221959259.png diff --git a/面试/面试/img/image-20230505222050294.png b/面试(收集)/img/image-20230505222050294.png similarity index 100% rename from 面试/面试/img/image-20230505222050294.png rename to 面试(收集)/img/image-20230505222050294.png diff --git a/面试/面试/img/image-20230505222126391.png b/面试(收集)/img/image-20230505222126391.png similarity index 100% rename from 面试/面试/img/image-20230505222126391.png rename to 面试(收集)/img/image-20230505222126391.png diff --git a/面试/面试/img/image-20230505222203615.png b/面试(收集)/img/image-20230505222203615.png similarity index 100% rename from 面试/面试/img/image-20230505222203615.png rename to 面试(收集)/img/image-20230505222203615.png diff --git a/面试/面试/img/image-20230505222319526.png b/面试(收集)/img/image-20230505222319526.png similarity index 100% rename from 面试/面试/img/image-20230505222319526.png rename to 面试(收集)/img/image-20230505222319526.png diff --git a/面试/面试/img/image-20230505223014946.png b/面试(收集)/img/image-20230505223014946.png similarity index 100% rename from 面试/面试/img/image-20230505223014946.png rename to 面试(收集)/img/image-20230505223014946.png diff --git a/面试/面试/img/image-20230505223219951.png b/面试(收集)/img/image-20230505223219951.png similarity index 100% rename from 面试/面试/img/image-20230505223219951.png rename to 面试(收集)/img/image-20230505223219951.png diff --git a/面试/面试/img/image-20230505223246059.png b/面试(收集)/img/image-20230505223246059.png similarity index 100% rename from 面试/面试/img/image-20230505223246059.png rename to 面试(收集)/img/image-20230505223246059.png diff --git a/面试/面试/img/image-20230505223442924.png b/面试(收集)/img/image-20230505223442924.png similarity index 100% rename from 面试/面试/img/image-20230505223442924.png rename to 面试(收集)/img/image-20230505223442924.png diff --git a/面试/面试/img/image-20230505223533130.png b/面试(收集)/img/image-20230505223533130.png similarity index 100% rename from 面试/面试/img/image-20230505223533130.png rename to 面试(收集)/img/image-20230505223533130.png diff --git a/面试/面试/img/image-20230505223536657.png b/面试(收集)/img/image-20230505223536657.png similarity index 100% rename from 面试/面试/img/image-20230505223536657.png rename to 面试(收集)/img/image-20230505223536657.png diff --git a/面试/面试/img/image-20230505223640038.png b/面试(收集)/img/image-20230505223640038.png similarity index 100% rename from 面试/面试/img/image-20230505223640038.png rename to 面试(收集)/img/image-20230505223640038.png diff --git a/面试/面试/img/image-20230505224057228.png b/面试(收集)/img/image-20230505224057228.png similarity index 100% rename from 面试/面试/img/image-20230505224057228.png rename to 面试(收集)/img/image-20230505224057228.png diff --git a/面试/面试/img/image-20230505224341410.png b/面试(收集)/img/image-20230505224341410.png similarity index 100% rename from 面试/面试/img/image-20230505224341410.png rename to 面试(收集)/img/image-20230505224341410.png diff --git a/面试/面试/img/image-20230505224626253.png b/面试(收集)/img/image-20230505224626253.png similarity index 100% rename from 面试/面试/img/image-20230505224626253.png rename to 面试(收集)/img/image-20230505224626253.png diff --git a/面试/面试/img/image-20230505224715087.png b/面试(收集)/img/image-20230505224715087.png similarity index 100% rename from 面试/面试/img/image-20230505224715087.png rename to 面试(收集)/img/image-20230505224715087.png diff --git a/面试/面试/img/image-20230505224755797.png b/面试(收集)/img/image-20230505224755797.png similarity index 100% rename from 面试/面试/img/image-20230505224755797.png rename to 面试(收集)/img/image-20230505224755797.png diff --git a/面试/面试/img/image-20230505224812015.png b/面试(收集)/img/image-20230505224812015.png similarity index 100% rename from 面试/面试/img/image-20230505224812015.png rename to 面试(收集)/img/image-20230505224812015.png diff --git a/面试/面试/img/image-20230505224857538.png b/面试(收集)/img/image-20230505224857538.png similarity index 100% rename from 面试/面试/img/image-20230505224857538.png rename to 面试(收集)/img/image-20230505224857538.png diff --git a/面试/面试/img/image-20230506094254360.png b/面试(收集)/img/image-20230506094254360.png similarity index 100% rename from 面试/面试/img/image-20230506094254360.png rename to 面试(收集)/img/image-20230506094254360.png diff --git a/面试/面试/img/image-20230506094411247.png b/面试(收集)/img/image-20230506094411247.png similarity index 100% rename from 面试/面试/img/image-20230506094411247.png rename to 面试(收集)/img/image-20230506094411247.png diff --git a/面试/面试/img/image-20230506094602329.png b/面试(收集)/img/image-20230506094602329.png similarity index 100% rename from 面试/面试/img/image-20230506094602329.png rename to 面试(收集)/img/image-20230506094602329.png diff --git a/面试/面试/img/image-20230506094735014.png b/面试(收集)/img/image-20230506094735014.png similarity index 100% rename from 面试/面试/img/image-20230506094735014.png rename to 面试(收集)/img/image-20230506094735014.png diff --git a/面试/面试/img/image-20230506094803545.png b/面试(收集)/img/image-20230506094803545.png similarity index 100% rename from 面试/面试/img/image-20230506094803545.png rename to 面试(收集)/img/image-20230506094803545.png diff --git a/面试/面试/img/image-20230506094938843.png b/面试(收集)/img/image-20230506094938843.png similarity index 100% rename from 面试/面试/img/image-20230506094938843.png rename to 面试(收集)/img/image-20230506094938843.png diff --git a/面试/面试/img/image-20230506095140595.png b/面试(收集)/img/image-20230506095140595.png similarity index 100% rename from 面试/面试/img/image-20230506095140595.png rename to 面试(收集)/img/image-20230506095140595.png diff --git a/面试/面试/img/image-20230506095306061.png b/面试(收集)/img/image-20230506095306061.png similarity index 100% rename from 面试/面试/img/image-20230506095306061.png rename to 面试(收集)/img/image-20230506095306061.png diff --git a/面试/面试/img/image-20230506095401637.png b/面试(收集)/img/image-20230506095401637.png similarity index 100% rename from 面试/面试/img/image-20230506095401637.png rename to 面试(收集)/img/image-20230506095401637.png diff --git a/面试/面试/img/image-20230506095504213.png b/面试(收集)/img/image-20230506095504213.png similarity index 100% rename from 面试/面试/img/image-20230506095504213.png rename to 面试(收集)/img/image-20230506095504213.png diff --git a/面试/面试/img/image-20230506095634842.png b/面试(收集)/img/image-20230506095634842.png similarity index 100% rename from 面试/面试/img/image-20230506095634842.png rename to 面试(收集)/img/image-20230506095634842.png diff --git a/面试/面试/img/image-20230506100142724.png b/面试(收集)/img/image-20230506100142724.png similarity index 100% rename from 面试/面试/img/image-20230506100142724.png rename to 面试(收集)/img/image-20230506100142724.png diff --git a/面试/面试/img/image-20230506100501905.png b/面试(收集)/img/image-20230506100501905.png similarity index 100% rename from 面试/面试/img/image-20230506100501905.png rename to 面试(收集)/img/image-20230506100501905.png diff --git a/面试/面试/img/image-20230506100548455.png b/面试(收集)/img/image-20230506100548455.png similarity index 100% rename from 面试/面试/img/image-20230506100548455.png rename to 面试(收集)/img/image-20230506100548455.png diff --git a/面试/面试/img/image-20230506100621146.png b/面试(收集)/img/image-20230506100621146.png similarity index 100% rename from 面试/面试/img/image-20230506100621146.png rename to 面试(收集)/img/image-20230506100621146.png diff --git a/面试/面试/img/image-20230506100746624.png b/面试(收集)/img/image-20230506100746624.png similarity index 100% rename from 面试/面试/img/image-20230506100746624.png rename to 面试(收集)/img/image-20230506100746624.png diff --git a/面试/面试/img/image-20230506100848497.png b/面试(收集)/img/image-20230506100848497.png similarity index 100% rename from 面试/面试/img/image-20230506100848497.png rename to 面试(收集)/img/image-20230506100848497.png diff --git a/面试/面试/img/image-20230506100920042.png b/面试(收集)/img/image-20230506100920042.png similarity index 100% rename from 面试/面试/img/image-20230506100920042.png rename to 面试(收集)/img/image-20230506100920042.png diff --git a/面试/面试/img/image-20230506101032605.png b/面试(收集)/img/image-20230506101032605.png similarity index 100% rename from 面试/面试/img/image-20230506101032605.png rename to 面试(收集)/img/image-20230506101032605.png diff --git a/面试/面试/img/image-20230506101115674.png b/面试(收集)/img/image-20230506101115674.png similarity index 100% rename from 面试/面试/img/image-20230506101115674.png rename to 面试(收集)/img/image-20230506101115674.png diff --git a/面试/面试/img/image-20230506101213373.png b/面试(收集)/img/image-20230506101213373.png similarity index 100% rename from 面试/面试/img/image-20230506101213373.png rename to 面试(收集)/img/image-20230506101213373.png diff --git a/面试/面试/img/image-20230506101420202.png b/面试(收集)/img/image-20230506101420202.png similarity index 100% rename from 面试/面试/img/image-20230506101420202.png rename to 面试(收集)/img/image-20230506101420202.png diff --git a/面试/面试/img/image-20230506101445898.png b/面试(收集)/img/image-20230506101445898.png similarity index 100% rename from 面试/面试/img/image-20230506101445898.png rename to 面试(收集)/img/image-20230506101445898.png diff --git a/面试/面试/img/image-20230506101504632.png b/面试(收集)/img/image-20230506101504632.png similarity index 100% rename from 面试/面试/img/image-20230506101504632.png rename to 面试(收集)/img/image-20230506101504632.png diff --git a/面试/面试/img/image-20230506101625087.png b/面试(收集)/img/image-20230506101625087.png similarity index 100% rename from 面试/面试/img/image-20230506101625087.png rename to 面试(收集)/img/image-20230506101625087.png diff --git a/面试/面试/img/image-20230506101641837.png b/面试(收集)/img/image-20230506101641837.png similarity index 100% rename from 面试/面试/img/image-20230506101641837.png rename to 面试(收集)/img/image-20230506101641837.png diff --git a/面试/面试/img/image-20230506101824622.png b/面试(收集)/img/image-20230506101824622.png similarity index 100% rename from 面试/面试/img/image-20230506101824622.png rename to 面试(收集)/img/image-20230506101824622.png diff --git a/面试/面试/img/image-20230506102311951.png b/面试(收集)/img/image-20230506102311951.png similarity index 100% rename from 面试/面试/img/image-20230506102311951.png rename to 面试(收集)/img/image-20230506102311951.png diff --git a/面试/面试/img/image-20230506104954777.png b/面试(收集)/img/image-20230506104954777.png similarity index 100% rename from 面试/面试/img/image-20230506104954777.png rename to 面试(收集)/img/image-20230506104954777.png diff --git a/面试/面试/img/image-20230506111102825.png b/面试(收集)/img/image-20230506111102825.png similarity index 100% rename from 面试/面试/img/image-20230506111102825.png rename to 面试(收集)/img/image-20230506111102825.png diff --git a/面试/面试/img/image-20230506111136231.png b/面试(收集)/img/image-20230506111136231.png similarity index 100% rename from 面试/面试/img/image-20230506111136231.png rename to 面试(收集)/img/image-20230506111136231.png diff --git a/面试/面试/img/image-20230506111255401.png b/面试(收集)/img/image-20230506111255401.png similarity index 100% rename from 面试/面试/img/image-20230506111255401.png rename to 面试(收集)/img/image-20230506111255401.png diff --git a/面试/面试/img/image-20230506111327590.png b/面试(收集)/img/image-20230506111327590.png similarity index 100% rename from 面试/面试/img/image-20230506111327590.png rename to 面试(收集)/img/image-20230506111327590.png diff --git a/面试/面试/img/image-20230506111512450.png b/面试(收集)/img/image-20230506111512450.png similarity index 100% rename from 面试/面试/img/image-20230506111512450.png rename to 面试(收集)/img/image-20230506111512450.png diff --git a/面试/面试/img/image-20230506111801764.png b/面试(收集)/img/image-20230506111801764.png similarity index 100% rename from 面试/面试/img/image-20230506111801764.png rename to 面试(收集)/img/image-20230506111801764.png diff --git a/面试/面试/img/image-20230506111817938.png b/面试(收集)/img/image-20230506111817938.png similarity index 100% rename from 面试/面试/img/image-20230506111817938.png rename to 面试(收集)/img/image-20230506111817938.png diff --git a/面试/面试/img/image-20230506111834758.png b/面试(收集)/img/image-20230506111834758.png similarity index 100% rename from 面试/面试/img/image-20230506111834758.png rename to 面试(收集)/img/image-20230506111834758.png diff --git a/面试/面试/img/image-20230506111919008.png b/面试(收集)/img/image-20230506111919008.png similarity index 100% rename from 面试/面试/img/image-20230506111919008.png rename to 面试(收集)/img/image-20230506111919008.png diff --git a/面试/面试/img/image-20230506111957793.png b/面试(收集)/img/image-20230506111957793.png similarity index 100% rename from 面试/面试/img/image-20230506111957793.png rename to 面试(收集)/img/image-20230506111957793.png diff --git a/面试/面试/img/image-20230506112047190.png b/面试(收集)/img/image-20230506112047190.png similarity index 100% rename from 面试/面试/img/image-20230506112047190.png rename to 面试(收集)/img/image-20230506112047190.png diff --git a/面试/面试/img/image-20230506131229649.png b/面试(收集)/img/image-20230506131229649.png similarity index 100% rename from 面试/面试/img/image-20230506131229649.png rename to 面试(收集)/img/image-20230506131229649.png diff --git a/面试/面试/img/image-20230506131308654.png b/面试(收集)/img/image-20230506131308654.png similarity index 100% rename from 面试/面试/img/image-20230506131308654.png rename to 面试(收集)/img/image-20230506131308654.png diff --git a/面试/面试/img/image-20230506131415418.png b/面试(收集)/img/image-20230506131415418.png similarity index 100% rename from 面试/面试/img/image-20230506131415418.png rename to 面试(收集)/img/image-20230506131415418.png diff --git a/面试/面试/img/image-20230506131442503.png b/面试(收集)/img/image-20230506131442503.png similarity index 100% rename from 面试/面试/img/image-20230506131442503.png rename to 面试(收集)/img/image-20230506131442503.png diff --git a/面试/面试/img/image-20230506131544447.png b/面试(收集)/img/image-20230506131544447.png similarity index 100% rename from 面试/面试/img/image-20230506131544447.png rename to 面试(收集)/img/image-20230506131544447.png diff --git a/面试/面试/img/image-20230506131607645.png b/面试(收集)/img/image-20230506131607645.png similarity index 100% rename from 面试/面试/img/image-20230506131607645.png rename to 面试(收集)/img/image-20230506131607645.png diff --git a/面试/面试/img/image-20230506131640893.png b/面试(收集)/img/image-20230506131640893.png similarity index 100% rename from 面试/面试/img/image-20230506131640893.png rename to 面试(收集)/img/image-20230506131640893.png diff --git a/面试/面试/img/image-20230506154006266.png b/面试(收集)/img/image-20230506154006266.png similarity index 100% rename from 面试/面试/img/image-20230506154006266.png rename to 面试(收集)/img/image-20230506154006266.png diff --git a/面试/面试/img/image-20230506154034531.png b/面试(收集)/img/image-20230506154034531.png similarity index 100% rename from 面试/面试/img/image-20230506154034531.png rename to 面试(收集)/img/image-20230506154034531.png diff --git a/面试/面试/img/image-20230506154042673.png b/面试(收集)/img/image-20230506154042673.png similarity index 100% rename from 面试/面试/img/image-20230506154042673.png rename to 面试(收集)/img/image-20230506154042673.png diff --git a/面试/面试/img/image-20230506154107944.png b/面试(收集)/img/image-20230506154107944.png similarity index 100% rename from 面试/面试/img/image-20230506154107944.png rename to 面试(收集)/img/image-20230506154107944.png diff --git a/面试/面试/img/image-20230506154117857.png b/面试(收集)/img/image-20230506154117857.png similarity index 100% rename from 面试/面试/img/image-20230506154117857.png rename to 面试(收集)/img/image-20230506154117857.png diff --git a/面试/面试/img/image-20230506154323950.png b/面试(收集)/img/image-20230506154323950.png similarity index 100% rename from 面试/面试/img/image-20230506154323950.png rename to 面试(收集)/img/image-20230506154323950.png diff --git a/面试/面试/img/image-20230506154542687.png b/面试(收集)/img/image-20230506154542687.png similarity index 100% rename from 面试/面试/img/image-20230506154542687.png rename to 面试(收集)/img/image-20230506154542687.png diff --git a/面试/面试/img/image-20230506154607558.png b/面试(收集)/img/image-20230506154607558.png similarity index 100% rename from 面试/面试/img/image-20230506154607558.png rename to 面试(收集)/img/image-20230506154607558.png diff --git a/面试/面试/img/image-20230506154633118.png b/面试(收集)/img/image-20230506154633118.png similarity index 100% rename from 面试/面试/img/image-20230506154633118.png rename to 面试(收集)/img/image-20230506154633118.png diff --git a/面试/面试/img/image-20230506154705088.png b/面试(收集)/img/image-20230506154705088.png similarity index 100% rename from 面试/面试/img/image-20230506154705088.png rename to 面试(收集)/img/image-20230506154705088.png diff --git a/面试/面试/img/image-20230506154759809.png b/面试(收集)/img/image-20230506154759809.png similarity index 100% rename from 面试/面试/img/image-20230506154759809.png rename to 面试(收集)/img/image-20230506154759809.png diff --git a/面试/面试/img/image-20230506154826981.png b/面试(收集)/img/image-20230506154826981.png similarity index 100% rename from 面试/面试/img/image-20230506154826981.png rename to 面试(收集)/img/image-20230506154826981.png diff --git a/面试/面试/img/image-20230506154859985.png b/面试(收集)/img/image-20230506154859985.png similarity index 100% rename from 面试/面试/img/image-20230506154859985.png rename to 面试(收集)/img/image-20230506154859985.png diff --git a/面试/面试/img/image-20230506155000503.png b/面试(收集)/img/image-20230506155000503.png similarity index 100% rename from 面试/面试/img/image-20230506155000503.png rename to 面试(收集)/img/image-20230506155000503.png diff --git a/面试/面试/img/image-20230506155047765.png b/面试(收集)/img/image-20230506155047765.png similarity index 100% rename from 面试/面试/img/image-20230506155047765.png rename to 面试(收集)/img/image-20230506155047765.png diff --git a/面试/面试/img/image-20230506155116267.png b/面试(收集)/img/image-20230506155116267.png similarity index 100% rename from 面试/面试/img/image-20230506155116267.png rename to 面试(收集)/img/image-20230506155116267.png diff --git a/面试/面试/img/image-20230506155146370.png b/面试(收集)/img/image-20230506155146370.png similarity index 100% rename from 面试/面试/img/image-20230506155146370.png rename to 面试(收集)/img/image-20230506155146370.png diff --git a/面试/面试/img/image-20230506155341703.png b/面试(收集)/img/image-20230506155341703.png similarity index 100% rename from 面试/面试/img/image-20230506155341703.png rename to 面试(收集)/img/image-20230506155341703.png diff --git a/面试/面试/img/image-20230506155416293.png b/面试(收集)/img/image-20230506155416293.png similarity index 100% rename from 面试/面试/img/image-20230506155416293.png rename to 面试(收集)/img/image-20230506155416293.png diff --git a/面试/面试/img/image-20230506155501557.png b/面试(收集)/img/image-20230506155501557.png similarity index 100% rename from 面试/面试/img/image-20230506155501557.png rename to 面试(收集)/img/image-20230506155501557.png diff --git a/面试/面试/img/image-20230506155518510.png b/面试(收集)/img/image-20230506155518510.png similarity index 100% rename from 面试/面试/img/image-20230506155518510.png rename to 面试(收集)/img/image-20230506155518510.png diff --git a/面试/面试/img/image-20230506155552693.png b/面试(收集)/img/image-20230506155552693.png similarity index 100% rename from 面试/面试/img/image-20230506155552693.png rename to 面试(收集)/img/image-20230506155552693.png diff --git a/面试/面试/img/image-20230506155704119.png b/面试(收集)/img/image-20230506155704119.png similarity index 100% rename from 面试/面试/img/image-20230506155704119.png rename to 面试(收集)/img/image-20230506155704119.png diff --git a/面试/面试/img/image-20230521101639915.png b/面试(收集)/img/image-20230521101639915.png similarity index 100% rename from 面试/面试/img/image-20230521101639915.png rename to 面试(收集)/img/image-20230521101639915.png diff --git a/面试/面试/img/image-20230521102022928.png b/面试(收集)/img/image-20230521102022928.png similarity index 100% rename from 面试/面试/img/image-20230521102022928.png rename to 面试(收集)/img/image-20230521102022928.png diff --git a/面试/面试/img/image-20230521102122950.png b/面试(收集)/img/image-20230521102122950.png similarity index 100% rename from 面试/面试/img/image-20230521102122950.png rename to 面试(收集)/img/image-20230521102122950.png diff --git a/面试/面试/img/image-20230521102135897.png b/面试(收集)/img/image-20230521102135897.png similarity index 100% rename from 面试/面试/img/image-20230521102135897.png rename to 面试(收集)/img/image-20230521102135897.png diff --git a/面试/面试/img/image-20230521102156863.png b/面试(收集)/img/image-20230521102156863.png similarity index 100% rename from 面试/面试/img/image-20230521102156863.png rename to 面试(收集)/img/image-20230521102156863.png diff --git a/面试/面试/img/image-20230521102319997.png b/面试(收集)/img/image-20230521102319997.png similarity index 100% rename from 面试/面试/img/image-20230521102319997.png rename to 面试(收集)/img/image-20230521102319997.png diff --git a/面试/面试/img/image-20230521102452501.png b/面试(收集)/img/image-20230521102452501.png similarity index 100% rename from 面试/面试/img/image-20230521102452501.png rename to 面试(收集)/img/image-20230521102452501.png diff --git a/面试/面试/img/image-20230521104457397.png b/面试(收集)/img/image-20230521104457397.png similarity index 100% rename from 面试/面试/img/image-20230521104457397.png rename to 面试(收集)/img/image-20230521104457397.png diff --git a/面试/面试/img/image-20230521104504491.png b/面试(收集)/img/image-20230521104504491.png similarity index 100% rename from 面试/面试/img/image-20230521104504491.png rename to 面试(收集)/img/image-20230521104504491.png diff --git a/面试/面试/img/image-20230521104952085.png b/面试(收集)/img/image-20230521104952085.png similarity index 100% rename from 面试/面试/img/image-20230521104952085.png rename to 面试(收集)/img/image-20230521104952085.png diff --git a/面试/面试/img/image-20230521105012350.png b/面试(收集)/img/image-20230521105012350.png similarity index 100% rename from 面试/面试/img/image-20230521105012350.png rename to 面试(收集)/img/image-20230521105012350.png diff --git a/面试/面试/img/image-20230521105346337.png b/面试(收集)/img/image-20230521105346337.png similarity index 100% rename from 面试/面试/img/image-20230521105346337.png rename to 面试(收集)/img/image-20230521105346337.png diff --git a/面试/面试/img/image-20230521111455359.png b/面试(收集)/img/image-20230521111455359.png similarity index 100% rename from 面试/面试/img/image-20230521111455359.png rename to 面试(收集)/img/image-20230521111455359.png diff --git a/面试/面试/img/image-20230521111943231.png b/面试(收集)/img/image-20230521111943231.png similarity index 100% rename from 面试/面试/img/image-20230521111943231.png rename to 面试(收集)/img/image-20230521111943231.png diff --git a/面试/面试/img/image-20230521112108550.png b/面试(收集)/img/image-20230521112108550.png similarity index 100% rename from 面试/面试/img/image-20230521112108550.png rename to 面试(收集)/img/image-20230521112108550.png diff --git a/面试/面试/img/image-20230521112731617.png b/面试(收集)/img/image-20230521112731617.png similarity index 100% rename from 面试/面试/img/image-20230521112731617.png rename to 面试(收集)/img/image-20230521112731617.png diff --git a/面试/面试/img/image-20230521112802886.png b/面试(收集)/img/image-20230521112802886.png similarity index 100% rename from 面试/面试/img/image-20230521112802886.png rename to 面试(收集)/img/image-20230521112802886.png diff --git a/面试/面试/img/image-20230521112831065.png b/面试(收集)/img/image-20230521112831065.png similarity index 100% rename from 面试/面试/img/image-20230521112831065.png rename to 面试(收集)/img/image-20230521112831065.png diff --git a/面试/面试/img/image-20230521113500488.png b/面试(收集)/img/image-20230521113500488.png similarity index 100% rename from 面试/面试/img/image-20230521113500488.png rename to 面试(收集)/img/image-20230521113500488.png diff --git a/面试/面试/img/image-20230521113544219.png b/面试(收集)/img/image-20230521113544219.png similarity index 100% rename from 面试/面试/img/image-20230521113544219.png rename to 面试(收集)/img/image-20230521113544219.png diff --git a/面试/面试/img/image-20230521113906521.png b/面试(收集)/img/image-20230521113906521.png similarity index 100% rename from 面试/面试/img/image-20230521113906521.png rename to 面试(收集)/img/image-20230521113906521.png diff --git a/面试/面试/img/image-20230521113941467.png b/面试(收集)/img/image-20230521113941467.png similarity index 100% rename from 面试/面试/img/image-20230521113941467.png rename to 面试(收集)/img/image-20230521113941467.png diff --git a/面试/面试/img/image-20230521114305463.png b/面试(收集)/img/image-20230521114305463.png similarity index 100% rename from 面试/面试/img/image-20230521114305463.png rename to 面试(收集)/img/image-20230521114305463.png diff --git a/面试/面试/img/image-20230521114432028.png b/面试(收集)/img/image-20230521114432028.png similarity index 100% rename from 面试/面试/img/image-20230521114432028.png rename to 面试(收集)/img/image-20230521114432028.png diff --git a/面试/面试/img/image-20230521124717749.png b/面试(收集)/img/image-20230521124717749.png similarity index 100% rename from 面试/面试/img/image-20230521124717749.png rename to 面试(收集)/img/image-20230521124717749.png diff --git a/面试/面试/img/image-20230521125012727.png b/面试(收集)/img/image-20230521125012727.png similarity index 100% rename from 面试/面试/img/image-20230521125012727.png rename to 面试(收集)/img/image-20230521125012727.png diff --git a/面试/面试/img/image-20230521125136717.png b/面试(收集)/img/image-20230521125136717.png similarity index 100% rename from 面试/面试/img/image-20230521125136717.png rename to 面试(收集)/img/image-20230521125136717.png diff --git a/面试/面试/img/image-20230521232030067.png b/面试(收集)/img/image-20230521232030067.png similarity index 100% rename from 面试/面试/img/image-20230521232030067.png rename to 面试(收集)/img/image-20230521232030067.png diff --git a/面试/面试/img/image-20230521232726959.png b/面试(收集)/img/image-20230521232726959.png similarity index 100% rename from 面试/面试/img/image-20230521232726959.png rename to 面试(收集)/img/image-20230521232726959.png diff --git a/面试/面试/img/image-20230521232850921.png b/面试(收集)/img/image-20230521232850921.png similarity index 100% rename from 面试/面试/img/image-20230521232850921.png rename to 面试(收集)/img/image-20230521232850921.png diff --git a/面试/面试/img/image-20230521232913086.png b/面试(收集)/img/image-20230521232913086.png similarity index 100% rename from 面试/面试/img/image-20230521232913086.png rename to 面试(收集)/img/image-20230521232913086.png diff --git a/面试/面试/img/image-20230521233150276.png b/面试(收集)/img/image-20230521233150276.png similarity index 100% rename from 面试/面试/img/image-20230521233150276.png rename to 面试(收集)/img/image-20230521233150276.png diff --git a/面试/面试/img/image-20230521233220905.png b/面试(收集)/img/image-20230521233220905.png similarity index 100% rename from 面试/面试/img/image-20230521233220905.png rename to 面试(收集)/img/image-20230521233220905.png diff --git a/面试/面试/img/image-20230521233554657.png b/面试(收集)/img/image-20230521233554657.png similarity index 100% rename from 面试/面试/img/image-20230521233554657.png rename to 面试(收集)/img/image-20230521233554657.png diff --git a/面试/面试/img/image-20230521233600556.png b/面试(收集)/img/image-20230521233600556.png similarity index 100% rename from 面试/面试/img/image-20230521233600556.png rename to 面试(收集)/img/image-20230521233600556.png diff --git a/面试/面试/img/image-20230521233715574.png b/面试(收集)/img/image-20230521233715574.png similarity index 100% rename from 面试/面试/img/image-20230521233715574.png rename to 面试(收集)/img/image-20230521233715574.png diff --git a/面试/面试/img/image-20230521233926897.png b/面试(收集)/img/image-20230521233926897.png similarity index 100% rename from 面试/面试/img/image-20230521233926897.png rename to 面试(收集)/img/image-20230521233926897.png diff --git a/面试/面试/img/image-20230521233934644.png b/面试(收集)/img/image-20230521233934644.png similarity index 100% rename from 面试/面试/img/image-20230521233934644.png rename to 面试(收集)/img/image-20230521233934644.png diff --git a/面试/面试/img/简单工厂.jpg b/面试(收集)/img/简单工厂.jpg similarity index 100% rename from 面试/面试/img/简单工厂.jpg rename to 面试(收集)/img/简单工厂.jpg diff --git a/面试/面试/img/线程池的执行原理.jpg b/面试(收集)/img/线程池的执行原理.jpg similarity index 100% rename from 面试/面试/img/线程池的执行原理.jpg rename to 面试(收集)/img/线程池的执行原理.jpg diff --git a/面试/面试/多线程相关面试题.md b/面试(收集)/多线程相关面试题.md similarity index 100% rename from 面试/面试/多线程相关面试题.md rename to 面试(收集)/多线程相关面试题.md diff --git a/面试/面试/常见技术场景.md b/面试(收集)/常见技术场景.md similarity index 100% rename from 面试/面试/常见技术场景.md rename to 面试(收集)/常见技术场景.md diff --git a/面试/面试/微服务面试题-参考回答.md b/面试(收集)/微服务面试题-参考回答.md similarity index 100% rename from 面试/面试/微服务面试题-参考回答.md rename to 面试(收集)/微服务面试题-参考回答.md diff --git a/面试/面试/框架篇面试题-参考回答.md b/面试(收集)/框架篇面试题-参考回答.md similarity index 100% rename from 面试/面试/框架篇面试题-参考回答.md rename to 面试(收集)/框架篇面试题-参考回答.md diff --git a/面试/面试/消息中间件面试题-参考回答.md b/面试(收集)/消息中间件面试题-参考回答.md similarity index 100% rename from 面试/面试/消息中间件面试题-参考回答.md rename to 面试(收集)/消息中间件面试题-参考回答.md diff --git a/面试/面试/设计模式.md b/面试(收集)/设计模式.md similarity index 100% rename from 面试/面试/设计模式.md rename to 面试(收集)/设计模式.md